Compare commits

...
Author SHA1 Message Date
Nico ba2a4e629c Recover stalled flights locally and fly rejected route approaches
clang PCH / build (push) Canceled after 0s
GCC PCH / build (push) Canceled after 0s
2026-11-04 01:14:02 +01:00
Nico f8ee1d0ff6 Preserve instance groups during leader absence and travel 2026-11-04 01:13:53 +01:00
Nico 65e4abb0b9 Fix shared flight trail replacement and safe follower rejoining
Publish replacement landing targets, bound orphaned trail waits, and plan safe connections from the follower position. Support shared-route regroup landings and preserve catch-up flights after the leader lands. Add geometry, lifecycle and group travel regressions.
2026-11-03 19:59:56 +01:00
Nico 768810a82d Record confirmed Wintergrasp fix and scheduled 20:00 raid retest 2026-11-03 19:00:48 +01:00
Nico a6572242c6 Fix flight departure recovery and Wintergrasp mount permissions
Keep takeoff clearance until actual ascent, blacklist rejected dynamic bridges, and diagnose group ground-path rejection. Synchronize native Wintergrasp client mount permission with battle state and fix duplicate Zangarmarsh graveyard ownership. Add regression tests and generic testbot aura inspection.
2026-11-03 18:56:16 +01:00
Nico fe03ea8ca5 Fix VMAP probe handle leaks and tile model ownership
Close the tile stream opened during existence checks and validate parent-map fallback directly. Track each tile's successful model acquisitions so failed or partial loads cannot release references owned by dynamic objects or other tiles.

Add production-method regression tests covering file descriptor stability, transient acquisition failures, shared references, duplicate loads, partial files and parent-map overlaps. Reproduced both original defects on 6f8f59ec6d; build and test-server verification passed.
2026-11-03 13:31:04 +01:00
Nico 6f8f59ec6d Fix short-stop elevator travel and recover stranded passengers
clang PCH / build (push) Canceled after 0s
GCC PCH / build (push) Canceled after 0s
Require 500ms remaining dwell to begin elevator boarding, allow exits at stopped docks without a dwell budget, and preserve journeys across interrupted transitions. Recover elevator passengers to their original destination after five minutes.

Add regression coverage and elevator recording documentation. Verified production build and live round trips with a dedicated testbot; Mokrak also completed a natural elevator trip.
2026-11-03 01:21:13 +01:00
Nico ae0b7513d9 Publish validated group flight landings and extend moving trail handoffs 2026-11-02 10:03:52 +01:00
Nico 3056063f12 Allow validated vertical approaches to elevated flight landings
clang PCH / build (push) Canceled after 0s
GCC PCH / build (push) Canceled after 0s
2026-11-02 08:32:21 +01:00
Nico 2778cf9bdd Use journey-stable distance costs for clientless travel routes 2026-11-02 06:26:58 +01:00
Nico 3c361e435a Improve AdventureBot instance and flight navigation
clang PCH / build (push) Canceled after 0s
GCC PCH / build (push) Canceled after 0s
2026-11-01 17:00:59 +01:00
73 changed files with 5433 additions and 295 deletions

No files matched your search

@@ -0,0 +1,132 @@
# Ursachenanalyse: Raidtest 01.11.2026, 22/23 Uhr
Nur Diagnose; kein Produktionscode, Botplan, Loader oder Serverzustand geändert.
Branch feature/clientless-player-core; vorhandene Checkpoints/WIP erhalten.
## 1. Bestätigter Aufbruchblocker: falsche Zielvariante
Vorbereitung verwendete instance_visit_at_6354. BuildInstanceVariants in
AdventureLocationMgr.cpp bevorzugt bei Raids mit vorhandener 25er-Normalvariante
Difficulty1 für den Basisschlüssel. Difficulty0 wird als _d0 erzeugt.
Native MapDifficulty.dbc für Karte671: modes0/1/2/3 haben10/25/10/25 Plätze.
Dies wurde direkt aus der installierten DBC gelesen, nicht aus Namen geraten.
Die passende 10er-Normalvariante ist instance_visit_at_6354_d0; native Eligibility
für Renren bestätigt map671/content1/own1/invitation1 für beide Schlüssel.
Zusätzliche Inkonsistenz: InstanceCapacity=min(20,maxPlayers) begrenzt die 25er-
Variante auf20, während Name und Difficulty bei25 bleiben. GroupPlan übernimmt
InstanceCapacity. Complete verlangt exakt Capacity Mitglieder plus Rollen:
capacity20 benötigt2T/4H/14D; tatsächlich sind10Bots mit2T/2H/6D in Gruppe2.
Complete ist somit false, CheckFullGroup aktiviert keinen frühen Abreisehandoff.
Um23:00 blockiert Begin vor ResolveInstance/Queue(travel_to_node) in WaitingGroup.
Der korrekte Eingangspfad wird folglich gar nicht angefordert. Aktuell alle10
im selben raid-Intent/WaitingGroup. Kein Beleg für ein Problem der Bastionroute.
Der falsche Basisschlüssel war ein Fehler der Testvorbereitung. Die20/25-
Inkonsistenz ist ein separater Implementierungsfehler. Ein bloßer Teleport oder
Reisereset kann beides nicht beheben. Der vorhandene Variantentest erwartet
explizit20 für25er-Modi; Tests decken diese fehlerhafte Semantik daher nicht auf.
Keine Tests/Builds in dieser reinen Diagnose gestartet.
## 2. Bestätigte Zerstreuungskette: Reiseablehnung und ungebundener Fallback
Historisches rotiertes Navigationlog:22:00:02 guid159 bei
(-8864.300,682.378,97.943), ZielAllison(-8867.790,673.673,97.986).
NAV_PLAN calculated1/complete1/type0x1, Weg9.38yd,4Punkte,maxZDelta0.03,
finalDistance0.08 und reason ok. NAV_START trotzdem PathRejected.
Fünf Recoveryversuche zu anderen Graphankern schlagen ebenfalls fehl;
Flugfallback kann den Startplatz nicht erreichen.22:00:04 RECOVERY_FAILED und
INTENT_ARRIVAL_REJECT beim vereinbarten Treffpunkt.
Fallback schützt einen aktiven Instanzbesuch sowie Botgruppenmitglieder mit
GroupOverride, aber nicht den Treffpunkt einer Gruppenbildung beim Leader.
Er durchsucht ALLE Allowed-Locations und priorisiert andere Maps lediglich mit
Distanzaufschlag100000. Es gibt keine Bindung an vereinbarten Treffpunkt,
Sturmwind oder aktuelle Hauptstadt. Logs zeigen Allison->GildedRose->weitere
SW-Orte->Eisenschmiede->andere Hauptstädte, insgesamt37 Ersatzwahlen beim Leader.
AdoptGroup übernimmt jeweils den Laufzeitzustand des Leaders. Mitglieder
beenden laufende Taxi/Flug/Transport-Schritte vor dem Wechsel; deshalb verfolgen
sie zeitversetzt verschiedene alte Ziele und verteilen sich über Kontinente.
Die genaue geometrische Ursache der ERSTEN PathRejected-Ablehnung ist offen.
MMAP hat einen vollständigen Weg geliefert. Nachfolgende Ausführungsprüfungen
umfassen AllowsSegment und den für Gruppen installierten GroundPathValidator
(Höhenprobe/Steigung/LOS). Beide können ohne präzises NAV_REJECT-Log abbrechen.
Die vorhandenen Logs rechtfertigen keine eindeutige Schuldzuweisung an eine
bestimmte Höhen-/LOS-Probe. Zur Behebung diesen Ort gezielt reproduzieren und
Ablehnungszweig/Koordinaten/erwartete und gemessene Höhe diagnostizieren; keine
pauschale Abschaltung von Sicherheitsprüfungen.
## 3. Zwei beim Stundenwechsel verzögerte Mitglieder
Um23:00 warteten164/165 noch auf eine Leaderroute für die alte Gruppenbildung.
Queue hängt Botmitgliedern route_source an; der Executor wartet bis15Sekunden
auf eine geteilte Route, danach erfolgt eigenständige Anschlussplanung.
SetWanted fordert während aktiver Reise StopAtNode an; Switching wartet auf
Busy=false und führt gegebenenfalls einen Graphknoten-Handover aus.
Nachfolgende native Diagnosen: beide travel_to_node stormwind_tradecenter
Completed/Arrived und raid/WaitingGroup. Die anfängliche Aussage eines dauerhaften
Intent-Hängers ist nicht belegt; das beobachtete Warten war vorübergehend.
## Gezielte Korrekturen für einen Folgetest
- Testziel explizit instance_visit_at_6354_d0; Difficulty0/capacity10/2T2H6D
zusammen mit nativer Gruppen-Difficulty und Complete überprüfen.
- 25er-Modi nicht als20er-Gruppe für einen25er-Raid vollständig melden;
konsistente tatsächliche Kapazität oder ausdrückliche Nichtunterstützung.
- Formationstreffpunkt beim Leader stabil halten; Fehlschlag führt zu lokaler
Wiederholung/Diagnose, nicht automatischem Hauptstadtwechsel.
- Anfangsablehnung in GoldenerRose mit genauer Ausführungsdiagnostik reproduzieren.
- Regressionen für vollständige10er-Gruppe/früheAbreise, Treffpunktbindung bei
Reisefehler, Stundenwechsel mit ausstehender geteilter Route und20/25-Konsistenz.
Belege: checkpoints/2026-11-01-raid-2200/initial-navigation-failure.log,
investigation-console.log, reset-console.log und verified-db.tsv. Server.log
enthält die Formation-Fallbacks und Mergefolge. Gameplay/Instanzanreise unbestätigt.
## Zusatz: drei Nutzerfragen zu Portalen und Kolonne, ca.23:17–23:21
1. Eisenschmiede: Portalspawn217855/GO195141/Spell65728 ist gebunden an
edge1827 portal_use, enabled1/cost1, vonportal_go_217855_source nach
portal_go_217855_target (VerwüsteteLande). Renren erreichte Quelle tatsächlich;
23:09:48 folgte laut Log free_flight direkt zurBastion statt portal_use.
Start wurde verworfen: no reachable open takeoff point, outdoor0,
Diagnosecounter surface/indoor/area/column/mmap/movement=0/0/0/1/63/0.
Danach Recovery über virtuelleStraße wird vom safetyAdapter abgelehnt.
Also Portalbenutzung in dieser Folge NICHT ausgeführt, keine nachgewiesene
native Portalzugangsverweigerung. Replan anQuelle: start->free_flight wiederholt.
Plannerportalguard gilt nur isPortalSource && previous enthältCurrentState;
Quellknoten alsStartwurzel fällt dadurch aus demGuard. Ein interaktivesPortal
teleportiert anders als einAreaTrigger nicht beim bloßenAnlaufen.
2. Sturmwind-Schattenhochland: spawn200627/GO207694/Spell85545, edge1801,
Quelle(-8186.15,413.729,116.75), Ziel(-4890.07,-6626.74,10.2846), map0.
Enabled1/cost1. DB zeigt KEINE eingehende festeKante zurQuelle. Zurückliegender
Livebeleg Elanora16:57:17 benutztPortal nativ mit arrivalError0; Voraussetzungen
nicht pauschal alsGrund behaupten. Level80+-AdventureUnlock existiert.
Aktuelle Route zumBastioneingang: SWtradecenter->taxi2->taxi6->IFportalQuelle,
danach freieFlugplanung/Recovery. Nur dynamischerAnschluss kannSWPortal
verbinden (allgemeineBrücken max200yd, Flugkandidaten16/Plan und6/Ursprung).
Offline geometrische Kandidatenrangberechnung OHNEArea/Prereq/FlightFilter:
SWportalQuelle steht vonSWtradecenter sowietaxi2 sogar aufRang1 der
Makrokandidaten. Damit ist reineDistanzsortierung NICHT alsAblehnungsgrund
belegt. Es fehlen personenbezogene Logs zu Kandidatenbudget/Estimate-Ablehnung
und Folgeverbindung abPortalziel. Exakten verlorenenRoutenvergleich erst mit
gezieltemPlantrace behaupten. Keine Runtimeplanung/Teleport zuTest erzwungen.
3. Kolonne: AdventureInteractionMgr kehrt beiBotLed vorGroupTravelMgr::Follow
zurück. Botgruppenmitglieder adoptierenExecutorroute (AdoptRoute), teilen also
Makroziel/Route, nicht automatisch individuellePositionen. Die allgemeine
Formation()/Follow-Steuerung wird dabei nicht verwendet. AdaptRoad läuft
nur imStartRecordedEdgePath, nicht für lokaleMMAP-Zuläufe/local_bridge/start.
AdaptRoad erhält den gemeinsamenEndpunkt; wennSeitversatz unsicher ist, zweiter
Versuch mitside0. Damit kann selbstStraßenadapter aufgleicheLinie zurückfallen.
NativeTaxiflüge haben identischen nativenPfad ohne separateKolonnenversätze.
EigeneFreeFlight hat sharedTrail mit3.5s+slot*0.6s Startverzögerung nur wenn
passenderLeaderflightRecord mit gleichemZiel vorhanden. Kein allgemeiner
Abstandsregler über alleReisemodi; keinePortalWarteaufstellung.
Nutzerbeobachtung desÜbereinanderlaufens passt zu diesen Integrationslücken;
keinBeleg, dass jedeFormationkomponente deaktiviert wäre.
Belege Zusatz: ironforge-portal-route-evidence.log; Sourceprüfung der erwähnten
Methoden und nur lesendeWorldDB-Abfragen. KeineBot-/Codeänderung/Build/Restart.
@@ -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).
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,50 @@
# Adventure portal unlock build recovery
Safe logout was requested while the `-j10` build started in parallel. The build
completed successfully. At the five-real-minute installation boundary eleven
managed sessions remained (`combat=4`, `transport=0`, `flight/taxi=7`). `saveall`
completed before shutdown.
| GUID | Name | Faction | Saved position | Observed transition/action |
|---:|---|---|---|---|
| 26 | Arthoran | Alliance | map0 `-11080.00,-483.45,54.49` | failed bounded free flight; mounted hold |
| 60 | Ashani | Alliance | map530 `-3013.51,-13204.10,59.63` | native taxi 94 -> 27 |
| 77 | Drakogzug | Horde | map1 `1852.91,-4570.06,24.98` | no flight/transport; failed departure recovery |
| 80 | Noralin | Alliance | map530 `-3013.51,-13204.10,59.63` | native taxi 94 -> 27 |
| 90 | Dorndin | Alliance | map1 `7990.23,1296.75,59.63` | native taxi 27 -> 94 |
| 93 | Gargar | Horde | map571 `2277.05,5202.07,12.49` | flight arrived; zeppelin state arrived |
| 96 | Darden | Alliance | map0 `-9381.78,-59.96,93.02` | failed bounded free flight; mounted hold |
| 97 | Talzul | Horde | map1 `2035.92,-3467.29,171.71` | failed bounded free flight; mounted hold |
| 132 | Braneron | Horde | map0 `2602.82,-112.11,34.02` | idle; no flight/transport |
| 135 | Ragnhild | Alliance | map1 `7990.23,1296.75,59.63` | native taxi 27 -> 94 |
| 153 | Ragnli | Alliance | map0 `-11787.06,-3180.12,-29.71` | idle; no flight/transport |
Loader configuration and test admission remained disabled/paused through the
build, installation and restart window. Post-start recovery outcomes are added
below after verification.
## Post-start verification
- Fresh worldserver PID 174775 reached ready state; TCP 8085 and 8087 listen.
- All eleven recorded characters were loaded. Every prior free-flight, native
taxi and transport state was absent after login; ordinary schedules then
produced new behavior independently of the saved transition state.
- Targeted production-method tests exercise both paths: native GameObject use
when prerequisites pass, and the importer-validated fixed teleport when a
level-80+ AdventureBot lacks quest prerequisites. Arrival still requires a
changed clientless teleport sequence, correct map and <=5-yard target error.
- A level-85 Elanora live probe planned and traversed graph-bound capital portal
travel. The explicit Twilight Highlands acceptance and its actual native
condition state are documented below.
## Explicit Twilight Highlands acceptance
Elanora (level 85) was placed within native interaction range of Stormwind spawn
200627 and given only target `portal_go_200627_target`. Production navigation
log at 2026-11-01 16:57:17 records edge 1801 `portal_use`, spawn 200627, spell
85545, followed in the same second by `EDGE_COMPLETE` on map 0 with
`arrivalError=0.00`. Runtime area query at the destination resolved area 5140,
allowed, level band 84-85. The World DB has no spell-condition rows for 85545,
so this particular portal correctly used the unchanged native click path rather
than `PORTAL_ADVENTURE_UNLOCK`. The questless unlock path remains covered by the
production-method regression. Temporary Elanora profile and override removed.
@@ -0,0 +1,103 @@
# Generic flight-entry build recovery
- Safe logout requested at approximately 2026-11-01 12:02 CET after pausing
`Clientless.Adventure.Loader` and TestBot admission.
- The `-j10` build ran in parallel with the safe drain, per the corrected build
policy in `AGENTS.md`, and completed successfully.
- At 12:08 CET, more than five real minutes later, 13 managed sessions remained:
3 combat flags, 0 active transports and 9 native flight flags. The database
positions below are from the immediately preceding online snapshot; native
action/flight/transport diagnostics were also captured in the tmux console.
| GUID | Name | faction | saved map / position | observed state |
|---:|---|---|---|---|
| 40 | Drakagmok | Horde | 1 / -4777.22 -1264.89 15.78 | native taxi 22->42; no free-flight/transport record |
| 48 | Elaniel | Horde | 1 / -4647.84 -1609.87 23.93 | native taxi 22->42; no free-flight/transport record |
| 54 | Falaeriel | Horde | 0 / 1875.88 -5018.08 166.81 | native taxi chain; prior free flight arrived |
| 77 | Drakogzug | Horde | 1 / 1857.05 -4568.03 24.98 | travel completed; no active flight/transport |
| 80 | Noralin | Alliance | 1 / 7990.23 1296.75 59.63 | native taxi; old ship record already Arrived |
| 83 | Mahuwa | Horde | 571 / 4234.13 3346.45 391.19 | failed free flight/permission timeout |
| 87 | Moggra | Horde | 1 / -3230.20 -1945.24 156.47 | native taxi to node55 |
| 91 | Norara | Horde | 571 / 3588.17 6692.83 208.30 | failed bounded flight corridor |
| 104 | Lillini | Alliance | 1 / 2669.46 1667.13 393.02 | native taxi; old flight/ship records Arrived |
| 118 | Lillina | Alliance | 0 / -8768.04 583.35 133.27 | failed bounded flight corridor |
| 132 | Braneron | Horde | 0 / 2539.96 -53.78 28.11 | idle; no flight/transport state |
| 146 | Loraria | Horde | 1 / -4215.50 -1913.36 131.73 | native taxi 22->42 |
| 153 | Ragnli | Alliance | 0 / -11787.10 -3180.12 -29.71 | idle; no flight/transport state |
No bot was individually force-logged-out, teleported, detached, or removed to
enable the build. Installation/restart uses the documented five-minute clean
shutdown exception. Post-start recovery results must be appended below.
## Restart outcome
Pending.
## Second build drain
- A second safe drain was requested at approximately 12:15 CET for the
flight-budget correction. The incremental `-j10` build again ran in parallel
and succeeded.
- After the five-minute gate, only two sessions remained online; neither had an
active free-flight or transport record:
| GUID | Name | faction | saved map / position | observed state |
|---:|---|---|---|---|
| 77 | Drakogzug | Horde | 1 / 1852.91 -4570.06 24.98 | completed location travel; outdoors=0 |
| 153 | Ragnli | Alliance | 0 / -11787.10 -3180.12 -29.71 | idle; no flight/transport state |
The other three members of the earlier five-bot snapshot logged out safely
before this final snapshot. No individual force logout was used.
## Third build drain
- Final macro-connector prioritization build started in parallel with a safe
drain at approximately 15:47 CET and completed successfully with `-j10`.
- At the 15:52 CET installation gate, GUID 77 Drakogzug and GUID 153 Ragnli
remained in combat at the same positions recorded above. Both had zero active
free-flight and transport records. They were not interrupted or individually
logged out; the documented clean-shutdown exception was used.
## Fourth build drain (portal-continuation budget fix)
- Safe logout requested at approximately 2026-11-01 15:54:30 CET. The `-j10`
build started immediately in parallel and completed successfully.
- At the five-minute installation boundary, two clientless sessions remained;
neither was in free flight, taxi travel or on a transport:
- GUID 77, Drakogzug, Horde, map 1 at `(1852.91, -4570.06, 24.98)`, area
1637. Action had failed at `flight_departure`; no free-flight or transport
state was present.
- GUID 153, Ragnli, Alliance, map 0 at `(-11787.06, -3180.12, -29.71)`, area
72. Action executor was idle; no free-flight or transport state was present.
- GUID 168 Elanora completed her active movement state and logged out safely
before the boundary. The temporary route-test Lua override was removed from
the source tree before this build.
- Testbot admission remained paused. AdventureBot loader remained disabled in
runtime configuration throughout the build and restart window.
## Fifth build drain (destination-first probe fix)
- Safe logout requested at approximately 2026-11-01 16:04:15 CET. Build
`-j10` again ran immediately in parallel and succeeded.
- GUID 168 Elanora completed her active free-flight transition and logged out
safely. At the five-minute installation boundary GUID 77 Drakogzug and GUID
153 Ragnli remained online in the same saved positions recorded above.
- `saveall` completed before shutdown. Both remaining bots again reported no
free-flight or transport state. Drakogzug had a failed travel action at
`flight_departure`; Ragnli was idle. Test admission and the AdventureBot
loader remained paused/disabled.
## Final runtime result
- Fresh worldserver PID 172446 reached `(worldserver-daemon) ready`; TCP 8085
and 8087 are listening.
- GUIDs 77 and 153 were loaded after restart. Both had no stale free-flight or
transport state and resumed ordinary behavior.
- The controlled Elanora plan produced a valid direct free-flight route from a
regular graph node to `portal_at_6354_source`; no MMAP attachment was needed.
The Stormwind Twilight Highlands portal did not compete because edge 1801
correctly failed `PortalPrerequisites` for this character's native spell/
quest state. The relevant portal is otherwise the first geometric connector,
so this was not a probe-budget failure.
- Temporary Elanora override removed. Schedule restored. Loader configuration
restored to enabled and test admission resumed. Loader status confirmed
`enabled=1 schema=1 busy=0`, pools `50/5/50/5`.
@@ -0,0 +1,36 @@
# Instance/navigation first-fix build recovery
- Safe logout requested approximately 2026-11-01 11:26 CET after pausing
`Clientless.Adventure.Loader` and TestBot admission.
- Build was allowed to run in parallel by the user's explicit clarification;
installation/restart remained gated by zero bots or the five-minute limit.
- At 11:31 CET, nine managed bots remained after more than five minutes.
The server was therefore eligible for a clean shutdown under the agreed rule.
- The separately logged-in human `Allitester` (GUID 21) is not a managed bot.
| GUID | Name | faction | saved map/position | observed state before shutdown |
|---:|---|---|---|---|
| 40 | Drakagmok | Horde | 1 / -4009.801 -2003.480 121.296 | groupformation; native taxi chain; no free-flight/transport record |
| 43 | Fizzira | Alliance | 0 / -8830.011 635.672 119.259 | social; failed held free flight, 94 segments |
| 60 | Ashani | Alliance | 1 / 7990.230 1296.750 59.628 | leisure; no flight capability/state |
| 77 | Drakogzug | Horde | 1 / 1852.910 -4570.060 24.981 | economy; flight and local travel arrived |
| 83 | Mahuwa | Horde | 571 / 2906.229 6057.960 80.671 | profession; zeppelin arrived; free flight failed touchdown confirmation |
| 96 | Darden | Alliance | 0 / -9337.900 12.744 105.163 | profession; failed held free flight, 162 segments |
| 121 | Braneven | Horde | 0 / 1964.780 -1567.223 123.819 | rest; failed held free flight, 462 segments |
| 132 | Braneron | Horde | 0 / 2539.960 -53.784 28.107 | no intent/travel/flight/transport state |
| 153 | Ragnli | Alliance | 0 / -11787.057 -3180.120 -29.714 | no intent/travel/flight/transport state |
Aggregate immediately before diagnostics: nine sessions, three combat and six
flight flags. No bot was individually force-logged-out, teleported, detached or
removed to enable the build.
## Restart outcome
- Build `-j10`, installation and fresh startup succeeded.
- Fresh worldserver PID 153100 reached `(worldserver-daemon) ready...`; TCP
listeners 8085 and 8087 were verified.
- All nine bots were loaded explicitly before restoring the population loader.
- None retained an old free-flight or transport controller record. Normal new
intent travel/combat resumed, so no recovery teleport was necessary.
- Loader and TestBot admission were restored. Population status reported
`enabled=1 schema=1 busy=0`, targets `50/5/50/5`; staggered loading resumed.
@@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""Read-only, bounded observation and deterministic test report. No bot repairs."""
from pathlib import Path
import collections,datetime,json,os,re,subprocess,sys,time
import test_driver as d
BASE=d.BASE
REPORT=d.ROOT/'doc/clientless/2026-11-02-0800-test-report.md'
def append(path,value):
with path.open('a') as f:f.write(json.dumps(value,ensure_ascii=False)+'\n')
def load(path):
result=[]
if path.exists():
for line in path.read_text().splitlines():
try:result.append(json.loads(line))
except json.JSONDecodeError:pass
return result
def manifest():return json.loads((BASE/'manifest.json').read_text())
def alive():
r=subprocess.run(['pgrep','-x','worldserver'],text=True,capture_output=True)
result=[]
for value in r.stdout.split():
try:
if os.readlink('/proc/'+value+'/exe')=='/home/wow_admin/wow_cata_server/server/debug/bin/worldserver':result.append(int(value))
except OSError:pass
return result
EVENT=re.compile(r'\b(?:GROUP_[A-Z_]+|INSTANCE_[A-Z_]+|INTENT_[A-Z_]+|EDGE_[A-Z_]+|RECOVERY_[A-Z_]+|NAV_REJECT|TEMP_TRAVEL|PORTAL_[A-Z_]+|TRAVEL_(?:PLAN[A-Z_]*|JOURNEY_COSTS|SHARED_ROUTE)|CPC_PERF)\b')
class Tail:
def __init__(self,path):self.path=path;self.file=None;self.initial=True
def poll(self):
try:
st=self.path.stat()
if self.file is None:
self.file=self.path.open();self.inode=st.st_ino
if self.initial:self.file.seek(0,2);self.initial=False
lines=self.file.readlines()
if self.inode!=st.st_ino:
self.file.close();self.file=self.path.open();self.inode=st.st_ino;lines+=self.file.readlines()
elif self.file.tell()>st.st_size:self.file.seek(0)
stamp=d.now().isoformat()
for line in lines:
match=EVENT.search(line)
if match:append(BASE/'events.jsonl',{'observed_at':stamp,'source':self.path.name,'event':match[0],'line':line.rstrip()})
except OSError as error:append(BASE/'observer-errors.jsonl',{'time':d.now().isoformat(),'error':str(error)})
def snapshot(native=True):
record={'time':d.now().isoformat(),'server_pids':alive()}
record['roster']=d.roster()
record['groups']=d.query('Character','SELECT g.guid,g.leaderGuid,g.groupType,m.memberGuid,c.name,c.level,c.online FROM groups g JOIN group_member m ON m.guid=g.guid JOIN characters c ON c.guid=m.memberGuid ORDER BY g.guid,m.memberGuid;')
record['plans']=d.plans();record['native']={}
if native and record['server_pids']:
raw=[];ids=[r['guid'] for r in record['roster'] if r['level']>=15]
for pos in range(0,len(ids),20):
chunk=ids[pos:pos+20]
for g in chunk:
for command in [f'clientless adventure status {g}',f'clientless actioninfo {g}',f'clientless interaction {g}',f'clientless adventure area {g}']:d.command(command)
time.sleep(1.5);text=d.console(1400);raw.append(text)
for g in chunk:
bot={}
for key,pattern in {
'behavior':rf'Adventure GUID {g}: enabled=(\d+) schedule=(\d+) phase=(\d+) intent=\x27([^\x27]*)\x27 location=\x27([^\x27]*)\x27.*?detail=\x27([^\x27]*)\x27',
'action':rf'Action GUID {g}: state=(\w+) action=\x27([^\x27]*)\x27 target=\x27([^\x27]*)\x27.*?detail=\x27([^\x27]*)\x27',
'area':rf'Area access GUID {g}: .*?map=(\d+) area=(\d+).*?allowed=(\d+)',
}.items():
matches=list(re.finditer(pattern,text))
if matches:bot[key]=list(matches[-1].groups())
record['native'][str(g)]=bot
(BASE/('snapshot-'+d.now().strftime('%Y%m%d-%H%M%S')+'.log')).write_text('\n'.join(raw))
append(BASE/'snapshots.jsonl',record)
return record
def observe():
end=datetime.datetime.fromisoformat(manifest()['evaluate_at'])
logs=Path('/home/wow_admin/wow_cata_server/server/debug/bin')
tails=[Tail(logs/'Server.log'),Tail(logs/'adventurebot-navigation.log')]
for tail in tails:tail.poll()
next_snapshot=0
append(BASE/'lifecycle.jsonl',{'event':'observer_started','time':d.now().isoformat(),'end':end.isoformat()})
while d.now()<end:
for tail in tails:tail.poll()
if time.monotonic()>=next_snapshot:
try:snapshot()
except Exception as error:append(BASE/'observer-errors.jsonl',{'time':d.now().isoformat(),'error':str(error)})
next_snapshot=time.monotonic()+300
# Preserve a final snapshot before the activity slot ends.
if (end-d.now()).total_seconds()<35:
try:snapshot()
except Exception as error:append(BASE/'observer-errors.jsonl',{'time':d.now().isoformat(),'error':str(error)})
while d.now()<end:
for tail in tails:tail.poll()
time.sleep(1)
break
time.sleep(2)
for tail in tails:tail.poll()
append(BASE/'lifecycle.jsonl',{'event':'observer_finished','time':d.now().isoformat()})
def report(preview=False):
m=manifest();end=datetime.datetime.fromisoformat(m['evaluate_at']);start=end.replace(hour=m['formation_hour'])
snapshots=[s for s in load(BASE/'snapshots.jsonl') if start<=datetime.datetime.fromisoformat(s['time'])<=end]
events=[]
for item in load(BASE/'events.jsonl'):
stamp=datetime.datetime.fromisoformat(item['observed_at'])
match=re.match(r'(\d{4}-\d\d-\d\d_\d\d:\d\d:\d\d)',item['line'])
if match:stamp=datetime.datetime.strptime(match[1],'%Y-%m-%d_%H:%M:%S').replace(tzinfo=d.TZ)
if start<=stamp<end:events.append(item)
# Server and navigation logs can repeat the same event; never count duplicates.
unique={}
for item in events:
line=re.sub(r'^.*?\[(?:server\.[^]]+)\]\s*','',item['line'])
key=(item['event'],line)
unique.setdefault(key,item)
events=list(unique.values());counts=collections.Counter(e['event'] for e in events)
arrivals=set();entered=set();cohort_entered=set();positions={};reasons=collections.Counter();plans=set()
cohort=set(m['raid_guids']);goal='instance_visit_at_6354_d0'
for e in events:
line=e['line'];g=re.search(r'\bguid=(\d+)\b',line)
guid=int(g[1]) if g else None
if guid is not None:
if e['event']=='INTENT_ARRIVED' and "intent='groupformation'" in line and any("location='"+v+"'" in line for v in m['meeting_locations'].values()):arrivals.add(guid)
if e['event']=='INSTANCE_ENTERED':
entered.add(guid)
if 'map=671 ' in line and 'mode=0' in line and guid in cohort:cohort_entered.add(guid)
if e['event']=='INSTANCE_PLAN' and guid in cohort:plans.add(guid)
if e['event']=='TEMP_TRAVEL' and 'position=' in line:positions[guid]=line
if e['event'] in ['RECOVERY_FAILED','PORTAL_USE_FAILED','INSTANCE_ENTRY_DENIED','INSTANCE_TRAVEL_RETRY']:
detail=re.search(r"(?:reason|status)='([^']*)'",line)
reasons[(e['event'],detail[1] if detail else 'siehe Rohlog')]+=1
max_group=0;together=False
rolemap={r['guid']:r.get('role','unknown') for r in m['roster']}
latest_group={}
for s in snapshots:
if not s['server_pids']:continue
grouped=collections.defaultdict(list)
for row in s['groups']:grouped[row['guid']].append(row)
for gid,rows in grouped.items():
members={int(r['memberGuid']) for r in rows};overlap=members&cohort
if len(overlap)>max_group:max_group=len(overlap)
if len(overlap)==10:together=True
latest_group=grouped
for snap in snapshots:
if not snap['server_pids']:continue
for guid,bot in snap.get('native',{}).items():
status=bot.get('behavior')
if status and status[2]=='2' and status[3]=='groupformation' and status[4] in m['meeting_locations'].values():arrivals.add(int(guid))
latest=snapshots[-1] if snapshots else None
leader_seeds={};copies=0;matched_seeds=0;unknown_seeds=0;mismatched_seeds=0
for e in events:
line=e['line']
if e['event']=='TRAVEL_JOURNEY_COSTS':
match=re.search(r'guid=(\d+) seed=(\d+)',line)
if match:leader_seeds[int(match[1])]=int(match[2])
elif e['event']=='TRAVEL_SHARED_ROUTE':
match=re.search(r'leader=(\d+).*?journeySeed=(\d+)',line)
if match:
copies+=1;leader=int(match[1]);seed=int(match[2])
if leader not in leader_seeds:unknown_seeds+=1
elif leader_seeds[leader]==seed:matched_seeds+=1
else:mismatched_seeds+=1
stamp=d.now();future=preview or stamp<end
title='Vorläufige Testbeobachtung' if future else 'Testauswertung'
lines=[f'# {title}: {start:%d.%m.%Y}, Gruppenbildung {m["formation_hour"]:02d}:00 / Instanzen {m["activity_hour"]:02d}:00',
'',f'Erstellt: {stamp:%d.%m.%Y %H:%M:%S %Z}. Geplanter Auswertungszeitpunkt: {end:%H:%M %Z}.',
'',f'- Geprüfte Testbots: {len(m["eligible_guids"])}. Unter Stufe 15 ausgenommen: {len(m["excluded_under15"])}.',
f'- Feste Treffpunkte: Allianz Goldene Rose in Sturmwind; Horde Broken Tusk in Orgrimmar.',
f'- Status-Snapshots: {len(snapshots)}; ausgewertete eindeutige Logereignisse: {len(events)}.',
f'- Erste erfasste Statusstichprobe: {snapshots[0]["time"] if snapshots else "keine"}; frühere Ereignisse sind nicht vollständig erfasst.',
f'- Bots mit belegter Ankunft am festgelegten Formationstreffpunkt: {len(arrivals)}.',
f'- Bots mit protokolliertem tatsächlichem Instanzbeitritt: {len(entered)}.',
f'- Snapshots ohne laufenden Testserver: {sum(not s["server_pids"] for s in snapshots)}.',
'', '## Bekannte Zehnergruppe: Bastion des Zwielichts, normal', '',
f'- Maximal gemeinsam in einer nativen Gruppe beobachtet: {max_group}/10.',
f'- Alle zehn gemeinsam nachgewiesen: {"ja" if together else "nein; durch die Stichproben nicht nachgewiesen"}.',
f'- Bots mit Instanzplanung: {len(plans)}/10; mit tatsächlichem Beitritt zu Karte 671 im Modus 0: {len(cohort_entered)}/10.',
'', '| GUID | Name | Rolle | Bastion betreten | Letzter nativer Status |', '|---:|---|---|---|---|']
byid={r['guid']:r for r in m['roster']}
for g in sorted(cohort):
row=byid.get(g,{});status=(latest or {}).get('native',{}).get(str(g),{}).get('behavior')
detail=(status[3]+' / '+status[4]+' / '+status[5]) if status else 'kein frischer Status erfasst'
lines.append(f'| {g} | {row.get("name","unbekannt")} | {rolemap.get(g,"unbekannt")} | {"ja" if g in cohort_entered else "nicht nachgewiesen"} | {detail.replace("|","/")} |')
lines+=['','## Übernahme der Anführerroute','',
f'- Protokollierte Routenübernahmen mit Journey-Seed: {copies}.',
f'- Gleicher zuletzt beobachteter Anführer-Seed: {matched_seeds}; abweichend: {mismatched_seeds}; Anführer-Seed im Beobachtungsfenster nicht bekannt: {unknown_seeds}.',
'- Ein abweichender letzter Seed erfordert Prüfung der konkreten Reise und des Zeitpunkts; diese Zählung allein beweist keinen Fehler.',
'', '## Fehler und Reiseereignisse','']
for key in ['GROUP_MERGE','GROUP_MANAGER_RUN','GROUP_EARLY_DEPART','PORTAL_USE_START','RECOVERY_FAILED','INSTANCE_TRAVEL_RETRY','INSTANCE_ENTRY_DENIED']:
lines.append(f'- {key}: {counts[key]}.')
if reasons:
lines+=['','Häufigste protokollierte Fehler:','']
for (kind,reason),count in reasons.most_common(12):lines.append(f'- {kind}: {reason} ({count} Ereignisse).')
lines+=['','## 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.',
f'- Letzter Snapshot: {latest["time"] if latest else "keiner"}.',
'',f'Rohdaten: {BASE.relative_to(d.ROOT)}/ (manifest.json, snapshots.jsonl, events.jsonl, snapshot-*.log).','']
target=BASE/'report-preview.md' if future else REPORT
tmp=target.with_suffix('.tmp');tmp.write_text('\n'.join(lines));tmp.replace(target)
(BASE/'report-status.json').write_text(json.dumps({'generated_at':stamp.isoformat(),'expected_at':end.isoformat(),'preview':future,'path':str(target),'snapshots':len(snapshots),'events':len(events),'cohort_entered':sorted(cohort_entered)},indent=2))
print(target)
if __name__=='__main__':
if sys.argv[1:]==['observe']:observe()
elif sys.argv[1:]==['snapshot']:print(json.dumps({'time':snapshot()['time']}))
elif sys.argv[1:]==['report-preview']:report(True)
elif sys.argv[1:]==['report']:report()
else:raise SystemExit('observe | snapshot | report | report-preview')
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""One-off native plan setup and read-only diagnostics; never prints credentials."""
from pathlib import Path
import datetime,json,os,re,subprocess,time
from zoneinfo import ZoneInfo
BASE=Path(__file__).resolve().parent
ROOT=BASE.parents[3]
CONF=Path('/home/wow_admin/wow_cata_server/server/debug/etc/worldserver.conf')
TZ=ZoneInfo('Europe/Berlin')
DATE=20261102
FORMATION=6
ACTIVITY=7
RAID=[159,160,161,162,163,164,165,166,167,176]
MEET={'alliance':'the_gilded_rose','horde':'poi_npc_287214'}
def now():return datetime.datetime.now(TZ)
def query(kind,sql):
if not re.match(r'^\s*(SELECT|SHOW)\b',sql,re.I):raise ValueError('Read-only database helper')
conf=CONF.read_text();value=re.search(r'^'+kind+r'DatabaseInfo\s*=\s*"([^"]+)"',conf,re.M).group(1)
h,port,u,password,db=value.split(';');env=os.environ.copy();env['MYSQL_PWD']=password
r=subprocess.run(['mysql','--protocol=TCP','--host='+h,'--port='+port,'--user='+u,'--database='+db,'--batch','--default-character-set=utf8mb4','--connect-timeout=10'],input=sql,text=True,capture_output=True,env=env,timeout=25)
if r.returncode:raise RuntimeError(r.stderr.replace(password,'[redacted]'))
lines=r.stdout.splitlines()
return [dict(zip(lines[0].split('\t'),line.split('\t'))) for line in lines[1:]] if lines else []
def command(text):
subprocess.run(['tmux','send-keys','-t','vcore-worldserver','-l',text],check=True,timeout=5)
subprocess.run(['tmux','send-keys','-t','vcore-worldserver','Enter'],check=True,timeout=5)
def console(lines=2000):
return subprocess.check_output(['tmux','capture-pane','-p','-J','-t','vcore-worldserver','-S',str(-lines)],text=True,timeout=10)
def roster():
rows=query('Character',"SELECT c.guid,c.name,c.race,c.level,c.online,c.map,c.instance_id,c.position_x,c.position_y,c.position_z,HEX(COALESCE(g.profile_lua,'')) profile FROM characters c LEFT JOIN vcore_adventure_generated_bot g ON g.character_guid=c.guid WHERE c.online=1 AND (c.guid=26 OR g.state='ready') ORDER BY c.guid;")
for r in rows:
for k in ['guid','race','level','online','map','instance_id']:r[k]=int(r[k])
r['faction']='alliance' if r['race'] in [1,3,4,7,11,22] else 'horde'
profile=bytes.fromhex(r.pop('profile')).decode(errors='replace')
role=re.search(r'\brole\s*=\s*["\'](tank|healer|damage)',profile)
r['role']=role[1] if role else ('tank' if r['guid']==26 else 'unknown')
return rows
def plans():return query('World',f'SELECT character_guid,hour_slot,intent,location_key FROM vcore_adventure_day WHERE local_date={DATE} AND hour_slot IN({FORMATION},{ACTIVITY}) ORDER BY character_guid,hour_slot;')
def apply():
rows=roster();p=BASE/'before-plans.json'
if not p.exists():p.write_text(json.dumps(plans(),indent=2))
current={(int(r['character_guid']),int(r['hour_slot'])):r for r in plans()}
pending=[]
for r in rows:
g=r['guid']
if r['level']<15:continue
a=current.get((g,ACTIVITY),{});f=current.get((g,FORMATION),{})
valid=a.get('intent') in ['raid','instance'] and f.get('intent')=='groupformation' and f.get('location_key')==MEET[r['faction']]
if g in RAID:valid=valid and a.get('location_key')=='instance_visit_at_6354_d0' and a.get('intent')=='raid'
if valid:continue
pending.append(g)
target='raid instance_visit_at_6354_d0' if g in RAID else 'instance-or-raid auto'
command(f'clientless adventure editplan {g} {ACTIVITY} {target} both 15 85 apply')
command(f'clientless adventure editplan {g} {FORMATION} groupformation {MEET[r["faction"]]} {r["faction"]} 15 85 apply')
time.sleep(2 if pending else .1)
current=plans();lookup={(int(r['character_guid']),int(r['hour_slot'])):r for r in current}
eligible=[];missing=[]
for r in rows:
if r['level']<15:continue
g=r['guid'];a=lookup.get((g,ACTIVITY),{});f=lookup.get((g,FORMATION),{})
good=f.get('intent')=='groupformation' and f.get('location_key')==MEET[r['faction']] and a.get('intent') in ['raid','instance']
if g in RAID:good=good and a.get('location_key')=='instance_visit_at_6354_d0' and a.get('intent')=='raid'
(eligible if good else missing).append(g)
manifest={'updated_at':now().isoformat(),'date':DATE,'formation_hour':FORMATION,'activity_hour':ACTIVITY,'evaluate_at':'2026-11-02T08:00:00+01:00','raid_guids':RAID,'meeting_locations':MEET,'roster':rows,'eligible_guids':eligible,'missing_plan_guids':missing,'excluded_under15':[r['guid'] for r in rows if r['level']<15],'plans':current}
(BASE/'manifest.json').write_text(json.dumps(manifest,ensure_ascii=False,indent=2))
(BASE/('plan-apply-'+now().strftime('%H%M%S')+'.log')).write_text(console())
print(json.dumps({'online':len(rows),'configured':len(eligible),'missing':missing,'newly_applied':pending,'excluded_under15':manifest['excluded_under15'],'raid_online':sorted(set(RAID)&{r['guid'] for r in rows})}))
return manifest
if __name__=='__main__':
import sys
if sys.argv[1:] == ['apply']:apply()
elif sys.argv[1:]==['roster']:print(json.dumps(roster(),ensure_ascii=False,indent=2))
@@ -0,0 +1,42 @@
"""Read-only observation of the ten-bot retry until 11:00 CET."""
from pathlib import Path
import sys,os,re,json,time,datetime
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'2026-11-02-0600-group-test'))
import test_driver as d
BASE=Path(__file__).resolve().parent
END=datetime.datetime.fromisoformat('2026-11-02T11:00:00+01:00')
IDS=set(d.RAID)
def append(name,v):
with (BASE/name).open('a') as f:f.write(json.dumps(v,ensure_ascii=False)+'\n')
class Tail:
def __init__(self,path):
self.path=path;self.f=path.open('rb');self.inode=os.fstat(self.f.fileno()).st_ino;self.f.seek(0,2);self.pending=b''
def poll(self):
st=self.path.stat()
if st.st_ino==self.inode and st.st_size<self.f.tell():self.f.seek(0);self.pending=b''
data=self.f.read()
if st.st_ino!=self.inode:
self.f.close();self.f=self.path.open('rb');self.inode=st.st_ino;data+=self.f.read()
parts=(self.pending+data).split(b'\n');self.pending=parts.pop()
for line in parts:
s=line.decode(errors='replace')
if any(int(g) in IDS for g in re.findall(r'\b(?:guid|leader|member|target)=(\d+)\b',s)):
append('events.jsonl',{'time':d.now().isoformat(),'source':self.path.name,'line':s})
def snapshot():
for g in d.RAID:
for c in [f'clientless adventure status {g}',f'clientless actioninfo {g}',f'clientless flight status {g}',f'clientless interaction {g}']:d.command(c)
time.sleep(.6)
stamp=d.now();file='snapshot-'+stamp.strftime('%H%M%S')+'.log';(BASE/file).write_text(d.console(1000))
groups=d.query('Character','SELECT g.guid,g.leaderGuid,m.memberGuid,c.name FROM groups g JOIN group_member m ON m.guid=g.guid JOIN characters c ON c.guid=m.memberGuid WHERE g.guid IN(SELECT guid FROM group_member WHERE memberGuid IN(159,160,161,162,163,164,165,166,167,176)) ORDER BY g.guid,m.memberGuid')
append('snapshots.jsonl',{'time':stamp.isoformat(),'native_file':file,'groups':groups})
if __name__=='__main__':
logs=Path('/home/wow_admin/wow_cata_server/server/debug/bin');tails=[Tail(logs/'Server.log'),Tail(logs/'adventurebot-navigation.log')];next_snapshot=0
append('lifecycle.jsonl',{'time':d.now().isoformat(),'event':'start','end':END.isoformat()})
while d.now()<END:
try:
for t in tails:t.poll()
if time.monotonic()>=next_snapshot:snapshot();next_snapshot=time.monotonic()+120
except Exception as e:append('errors.jsonl',{'time':d.now().isoformat(),'error':str(e)})
time.sleep(2)
for t in tails:t.poll()
append('lifecycle.jsonl',{'time':d.now().isoformat(),'event':'finished'})
@@ -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.
@@ -0,0 +1,75 @@
# Shared-flight landing and continuity fix — 02.11.2026
User observed followers turning away above the Bastion platform and repeated
pauses in flight. No group-refill policy changes are part of this fix.
## Root cause
09:34:53: leader Winienwi163 walks8yd to open takeoff column. His prepared
Flight Record already has Target but does not have a validated Landing yet.
09:34:55: followers166/159/161 attach to the leader trail BEFORE the leader's
landing-search result is published. Old Begin copied Status.Landing immediately,
which at this phase is the default zero Position. The shared pointer to the
trail survives preparation, but the copied landing never updated.
09:37:33: leader lands correctly at827.765625 and subsequently enters instance2.
09:37:49..52: followers reach the hover point837.765625. The zero-goal corridor
retains the8yd safety floor margin at the true touchdown and rejects descent.
Fail() therefore starts a checked breadcrumb return; this is not a successful
arrival or a deliberate new destination. Evidence root-cause-evidence.jsonl.
## Implementation
SharedTrail now publishes Landing and LandingReady. Begin does not copy the
leader's potentially uninitialised Record Position. Advance binds the validated
landing from the shared object, waiting for publication if preparation continues.
Followers retain a shared object even after the leader leaves for the instance.
Native attachment logs now include landingReady.
For continuity, handoff begins while up to1500ms remain rather than600ms.
Followers may advance to the next published endpoint before stopping at the old
endpoint, but only if the complete shortcut corridor is clear. Escape-leg corners
are retained. Aborted trails still fail closed. Explicit shared-reject diagnostics
include index, point count, abort flag and effective landing.
Eight targeted tests PASS. New regression reproduces the zero-goal terminal
failure, exercises the real Advance landing-binding prefix with delayed
publication, tests terminal descent, early running handoff, blocked corners,
escape legs and aborted trails. Existing ordinary/elevated flight, native handoff,
group follow, policy, landing search and terrain tests retained.
## Build and recovery
Build-j10 and install PASS. Safe logout requested09:49:23.822668; configuration
loader0 initially did not stop the cached loader. Explicit population reload then
confirmed enabled0 (loader-paused.log). This pause correction happened after the
build had begun and is documented rather than treating the earlier pause as
verified. Before installation, the full five-minute drain elapsed. Remaining14
bots, positions, native available flags and diagnostics were preserved in
final-remaining.json / final-remaining-native.log / last-available-remaining-flags.json.
Five-minute exception allowed clean server shutdown; no individual forced logout
or flight interruption was used to install. Process/tmux absence verified before
install. Fresh startup ready PID258518, ports8085/8087 verified.
Original ten-bot test evidence preserved. Only ten test bots' previous handoff and
instance-visit records were backed up and cleared to start a fresh run; all ten
natively teleported offline to the Golden Rose, reloaded, slots09formation and
10raid6354_d0 applied. Complete group1, leader163, all ten confirmed. Normal loader
restored (including explicit population reload); all14 recovery bots requested
login. Moving native taxi/flight actors left intact. Six unsupported stationary
actors74/77/91/93/102/121 received native capital recovery after login checks;
post-restart-native.log and recovery-capitals.log preserve findings/results.
Temporary GM teleport destination removed and cache reloaded. No production use.
Live result of the new10:00 run is recorded separately below when available.
## Live follow-up limitation
10:00 retry remains a complete ten-bot group led by163 with unchanged raid goal.
The initial route now stalls on the separately known ground safety issue: native
MMAP taxi_2 -> stormwind_tradecenter is complete (path189.55yd) but execution
returns PathRejected. Repeated departure approaches are interrupted. The group
has not reached the tower again as of10:02, so this run does NOT prove the final
follower descent or visually smooth flight. New landingReady=1 attachment and
short native flights were logged, but they are not a substitute for that proof.
All110 bots online, loader enabled1, original group still intact. Existing
observer remains active until11:00; no automatic chat report is promised.
The next independent blocker is the local ground safety/recovery behavior.
@@ -0,0 +1,74 @@
{
"configured_at": "2026-11-03T18:59:42.951826+01:00",
"date": 20261103,
"formation_hour": 19,
"raid_hour": 20,
"timezone": "Europe/Berlin",
"meeting": "the_gilded_rose",
"raid": "instance_visit_at_6354_d0",
"difficulty": "normal 10-player",
"roster": [
{
"guid": 159,
"name": "Renren",
"level": 85,
"role": "damage"
},
{
"guid": 160,
"name": "Marndra",
"level": 85,
"role": "damage"
},
{
"guid": 161,
"name": "Marnda",
"level": 85,
"role": "healer"
},
{
"guid": 162,
"name": "Aruendas",
"level": 85,
"role": "healer"
},
{
"guid": 163,
"name": "Winienwi",
"level": 85,
"role": "damage"
},
{
"guid": 164,
"name": "Taliaarnora",
"level": 85,
"role": "damage"
},
{
"guid": 165,
"name": "Brynda",
"level": 85,
"role": "damage"
},
{
"guid": 166,
"name": "Corric",
"level": 85,
"role": "damage"
},
{
"guid": 167,
"name": "Ragnorli",
"level": 85,
"role": "tank"
},
{
"guid": 176,
"name": "Ashaelri",
"level": 85,
"role": "tank"
}
],
"commit": "a6572242c6",
"observation": "user observes; no background observer"
}
+210
View File
@@ -0,0 +1,210 @@
# Fahrstühle im TravelGraph erfassen
Diese Anleitung beschreibt den vorhandenen Recorder. Sie verändert weder die
Fahrstuhlbewegung noch dessen Haltezeiten. Alle Aufnahmen erfolgen mit einem
normalen GM-Client im Spiel; `SPAWN_ID` und `BOT_GUID` sind zu ersetzen.
## Technische Einordnung
Ein geeigneter Fahrstuhl ist ein natives Transport-GameObject, üblicherweise
`GAMEOBJECT_TYPE_TRANSPORT` (Typ11). Schiffe und Zeppeline verwenden normalerweise
`GAMEOBJECT_TYPE_MO_TRANSPORT` (Typ15). Beide stellen `TransportBase` bereit;
daher werden dieselben Recorderbefehle verwendet. Ein Fahrstuhl ist dabei kein
Unit-Fahrzeug mit Sitzplätzen: Der GM läuft auf die Plattform und muss vom Server
als Passenger erkannt werden.
Der Graph bekommt je Haltestelle einen gemeinsamen Ein-/Ausstiegsnode auf festem
Boden. Separat werden Bodenanschluss und Land-Plattform-Übergang erfasst. Die
Fahrt selbst kommt aus der nativen Transportanimation.
```text
Bodennetz unten ↔ lift_ort_unten ↔ [Fahrstuhl] ↔ lift_ort_oben ↔ Bodennetz oben
```
## 1. Den richtigen Fahrstuhl bestimmen
Mit dem GM neben der Plattform stehen:
```text
.clientless transport near 30
```
Aus der Antwort `spawn=...` notieren. Das ist die konkrete **Spawn-ID**, nicht
`entry=...` und nicht der vollständige Objekt-GUID.
```text
.clientless transport inspect SPAWN_ID
```
Vor der Aufnahme muss `stopped=1` gelten. `remainingPause` ist die verbleibende
Haltezeit in Millisekunden; `4294967295` bedeutet einen zeitlich nicht begrenzten
Halt. Die Aufnahme braucht einen echten Halt und eine gültige positive Periode.
Ein sichtbarer Fahrstuhl mit `stopped=0` gilt noch nicht als sicher angedockt.
`transport services` listet die nativen Schiffs-/Zeppelinservices aus der
`transports`-Tabelle. Für gewöhnliche Typ11-Fahrstühle stattdessen `near`/`inspect`
verwenden.
## 2. Untere Haltestelle und Einstieg aufnehmen
Auf **festem Boden neben** dem unteren Einstieg stehen, nicht auf der beweglichen
Plattform. Den Node so nah an der Einstiegskante setzen, dass er sicher erreichbar
bleibt, wenn der Fahrstuhl gerade oben ist.
```text
.clientless node add lift_ort_unten elevator
```
Auf die nächste vollständige Ankunft des Fahrstuhls warten. Höchstens5 Yards vom
Node entfernt auf festem Boden die Aufnahme starten:
```text
.clientless transport record start lift_ort_unten SPAWN_ID
```
Nun normal auf die Plattform und zu einem sicheren Platz laufen. Nicht fliegen,
springen oder teleportieren. Noch während **desselben Halts** prüfen und speichern:
```text
.clientless transport record info
.clientless transport record stop lift_ort_mitte
```
Vor dem Speichern muss `aboard=1` erscheinen. `stop` speichert den Übergang und
legt den benannten Stehplatz bei Bedarf automatisch an. Eine separate
`transport stand add`-Aufnahme ist für diesen Ablauf nicht nötig.
Nur den kurzen Weg vom Land auf die Plattform aufnehmen. **Nicht erst oben
`record stop` ausführen:** Sobald der Fahrstuhl abfährt, wird die Aufnahme ungültig.
Die gesamte Einstiegsstrecke darf höchstens60 Yards lang sein.
## 3. Obere Haltestelle aufnehmen
Mit demselben Fahrstuhl nach oben fahren, aussteigen und auf festem Boden den
oberen Node anlegen:
```text
.clientless node add lift_ort_oben elevator
```
Am besten den nächsten vollständigen Halt abwarten, damit genügend Zeit bleibt.
Vom oberen Node wieder auf **dieselbe Plattform und denselben lokalen Stehplatz**
laufen und dort speichern:
```text
.clientless transport record start lift_ort_oben SPAWN_ID
.clientless transport record info
.clientless transport record stop lift_ort_mitte
.clientless transport stops
```
Denselben Standnamen verwenden. Weil er für den Transport-Entry bereits existiert,
muss der GM beim zweiten Speichern innerhalb1,5 Yards des gespeicherten lokalen
Punkts stehen. Für eine Verbindung dürfen die lokalen Deck-Endpunkte beider
Aufnahmen höchstens3 Yards auseinanderliegen. Ein Nachbaraufzug mit demselben
Entry ist trotzdem ein anderes Fahrzeug: Beide Aufnahmen brauchen denselben Spawn.
Die Aufnahme wird beim Aussteigen umgekehrt abgespielt. Deshalb müssen beide
Einstiegswege auch rückwärts begehbar sein.
## 4. Fahrtkante und Bodenanschlüsse anlegen
Für einen Fahrstuhl, der beide Richtungen bedient:
```text
.clientless transport link lift_ort_unten lift_ort_oben elevator 100 both
```
`both` erzeugt die Verbindung in beiden Richtungen. Ohne `both` wird nur die
angegebene Richtung angelegt. Der Befehl prüft die vorhandenen Haltestellen und
lädt den Graphen anschließend neu.
Die `100` ist weiterhin ein erforderlicher positiver Parameter des Linkbefehls.
Seit dem Entfernungskostenmodell bestimmt dieser alte DB-Kostenwert **nicht**
mehr die tatsächliche Routenbewertung. Diese verwendet die Länge der nativen
Transportbewegung × Multiplikator mit der Variation der jeweiligen Reise.
`elevator` verwendet aktuell denselben Multiplikator wie `tram`:
`Clientless.Travel.CostMultiplier.Tram`. Es gibt noch keinen eigenen Elevator-Faktor.
Beide Etagen an vorhandene Bodennodes anbinden. Beispiel: auf einem bereits
vorhandenen unteren Bodennode `ort_weg_unten` stehen:
```text
.clientless route start ort_weg_unten lift_ort_unten
```
Den normalen Bodenweg bis `lift_ort_unten` laufen, dann:
```text
.clientless route stop
```
Die Groundaufnahme wird bidirektional gespeichert. Oben entsprechend aufnehmen.
**Keine Groundkante durch den Schacht und keine Groundaufnahme während der Fahrt.**
Vollständig geprüfte MMAP-Anschlüsse sind ebenfalls möglich; eine aufgezeichnete
Bodenanbindung macht den gewünschten Zugang ausdrücklich nachvollziehbar.
## 5. Beide Richtungen mit einem Bot prüfen
Einen untätigen CPC-Bot am unteren Node verwenden. Ein noch laufender Tagesauftrag
wird durch `transport travel` nicht unterbrochen; für einen isolierten Test muss
der vorherige Auftrag erst sicher beendet sein.
```text
.clientless transport travel BOT_GUID lift_ort_oben
.clientless transport status BOT_GUID
.clientless actioninfo BOT_GUID
```
Der erwartete Ablauf ist `Waiting → Boarding → Riding → Leaving → Arrived`.
`Arrived` muss am oberen **Landnode** erreicht werden, nicht nur an Bord.
Anschließend dieselbe Prüfung zurück nach `lift_ort_unten` durchführen und danach
auch eine normale Weiterreise in das obere/untere Bodennetz testen.
`travel` fordert eine Reise zum Node über den normalen Planer an; es erzwingt
nicht die Fahrstuhlkante. Für den ersten Test möglichst einen Bot ohne Freiflug
und eine Strecke ohne günstigere Alternativverbindung wählen. Am Status prüfen,
ob tatsächlich eine Transportfahrt gestartet wurde.
## Fehlerbehebung und Grenzen
- `spawn ... not loaded`: GM auf die richtige Map und in Sichtweite des Fahrstuhls
bringen. Bei mehreren Plattformen den konkreten Spawn erneut mit `near` prüfen.
- `aboard=0`: Der Server erkennt den GM nicht als Passenger. So lässt sich keine
gültige Fahrstuhlverbindung speichern; reine sichtbare Mitbewegung genügt nicht.
- Abfahrt vor `record stop`, falscher Stehplatz oder falscher Transport:
`record info` lesen, dann `record cancel` und beim nächsten Halt neu aufnehmen.
- `Dock already recorded`: Eine gespeicherte Aufnahme wird nicht überschrieben.
Nur die betreffende Haltestelle mit `transport forget lift_ort_unten` entfernen
und neu aufnehmen. Node und Bodenrouten bleiben dabei erhalten.
- Wurde ein Haltestellennode verschoben, dessen Einstiegsaufnahme neu erstellen.
- `both` ist kein Nachweis erfolgreicher Fahrten: Ein-/Ausstieg, beide Richtungen
und verpasste Halte am echten Fahrstuhl testen.
- Der Controller wartet auf Halte und fährt als Passenger mit. Er hat keine
allgemeine Bedienlogik für Rufknöpfe, Hebel oder Etagenwahl. Manuell zu
aktivierende Fahrstühle benötigen dafür gegebenenfalls eine zusätzliche Logik.
- Beliebige Fahrstuhlmodelle haben nicht automatisch eine freie Decknavigation.
Der gespeicherte gemeinsame Stehplatz bleibt deshalb wichtig.
Diagnosen stehen in `adventurebot-navigation.log`. `transport reload` lädt die
Aufnahmen erneut; laufende Fahrten behalten ihre beim Start übernommenen Daten.
Grundlagen und weitere Verkehrsmittel: [transport-tools.md](transport-tools.md).
## Laufzeitverhalten der Bots
Bei nativen Fahrstühlen (GameObject-Typ 11) beginnen Einstieg und Ausstieg
nur am aufgezeichneten Halt, wenn die Plattform tatsächlich steht. Der Einstieg
beginnt erst bei mindestens 500 Millisekunden Reststandzeit; der Ausstieg
benötigt keine Mindestreststandzeit. Fährt sie während eines
Übergangs wieder los, bleibt eine vorhandene Fahrgastbindung erhalten.
Bleibt ein Bot fünf Minuten als Fahrgast hängen, bringt ihn ein nativer
Teleport zum aufgezeichneten Zielnode seiner Fahrt: oben eingestiegen führt
nach unten, unten eingestiegen nach oben. Fehlversuche oder Abbruch ändern
dieses ursprüngliche Fallbackziel nicht. Schiffe, Zeppeline und die Tiefenbahn
behalten ihre bisherigen Restzeitprüfungen und erhalten diesen Fallback nicht.
Bei einem nach Serverneustart übernommenen Fahrgast fehlt das ursprüngliche
Einstiegsereignis. Für den Fallback wird dann die nächstgelegene aufgezeichnete
Etage als Ausgangspunkt und ihre eindeutige Gegenetage als Ziel verwendet.
+8 -4
View File
@@ -176,10 +176,14 @@ entsprechend wichtig. Ein anderer Wagen ist eine andere Aufnahmeverbindung.
.clientless transport link lift_lower lift_upper elevator 100 both
```
Der Kostenwert ist ein positiver **Graphkostenwert**, keine automatisch mit
Groundlängen vergleichbare Sekundenzahl. Zum ersten isolierten Funktionstest
ist der genaue Wert unkritisch; konkurrierende Fernrouten benötigen später eine
gemeinsame Kostenkalibrierung. `both` ist optional und erzeugt eine bidirektionale
Der Linkbefehl verlangt weiterhin einen positiven Kostenparameter (hier100).
Seit dem Entfernungskostenmodell wird der alte DB-Wert bei der Routenplanung
jedoch durch die native Transportstrecke × Verkehrsmittel-Multiplikator und
die Variation der jeweiligen Reise ersetzt. Fahrstühle verwenden derzeit den
Tram-Multiplikator `Clientless.Travel.CostMultiplier.Tram`.
Für den vollständigen Fahrstuhlablauf siehe
[fahrstuehle-aufzeichnen.md](fahrstuehle-aufzeichnen.md). `both` ist optional und erzeugt eine bidirektionale
Kante. Es darf nur verwendet werden, wenn das reale Verkehrsmittel beide
Richtungen bedient. Ohne `both` entsteht nur die angegebene Fahrtrichtung.
+36
View File
@@ -0,0 +1,36 @@
# Reisekosten
Der Planer bewertet Strecken nach Entfernung × Verkehrsmittel-Multiplikator.
Geschwindigkeitsunterschiede werden wie vom Nutzer gewünscht ausschließlich über
die Multiplikatoren abgebildet. Es wird kein Reisezeitmodell verwendet.
Aufgezeichnete Bodenrouten verwenden ihre Linienlänge einschließlich der
Anschlüsse. Taxis verwenden native Flugpfadpunkte statt Fahrpreis oder alter
Pauschalkosten (Sturmwind–Eisenschmiede, Kante 15: 6457.25 Yards).
Schiffe, Zeppeline und Tram verwenden native Transportgeometrie; Kartenwechsel
und Teleports erzeugen keine künstlichen Koordinatensprünge. Statische
Transportanimationen berücksichtigen Spawnposition und ParentRotation.
Freiflug und lokale Anschlüsse verwenden ihre geometrische Entfernung.
Portale erhalten eine Basis von 1, da die Entfernung zwischen Karten keinen
sinnvollen physischen Reiseweg beschreibt. Unmessbare Verbindungen werden nicht
mit erfundenen niedrigen Kosten bevorzugt. Direkter Flug konkurriert mit dem
Graphen, statt unabhängig von dessen Kosten sofort ausgewählt zu werden.
Konfiguration: `Clientless.Travel.CostMultiplier.Ground`, `.Taxi`, `.Flight`,
`.Ship`, `.Zeppelin`, `.Tram`, `.Portal`; derzeit alle 1. Faktoren werden auf
0.01 bis 100 begrenzt. `Clientless.Travel.CostVariation` ist derzeit 0.20
(zulässig 0 bis 0.90). Pro Reise wird jeder Verkehrsmittelfaktor einmal unabhängig
variiert; innerhalb der Reise bleiben die Gewichte stabil. Ein neues Ziel oder
eine nach Abschluss begonnene Reise erhält ein neues Profil. Fehlgeschlagene
Wiederholungen und Recovery derselben Reise behalten das bestehende Profil.
Gruppenmitglieder übernehmen Route und Kostenprofil des Anführers. Auch
Anschlussplanung und Recovery verwenden dieses Profil. Native Logs enthalten
Journey-Seeds und Faktoren zur Überprüfung; eine gemeinsame Makroroute garantiert
keine räumlich versetzte Kolonnenbewegung.
Validierung: acht gezielte Regressionstests bestanden, CMake-Build mit -j10 und
Installation erfolgreich. Native Taxi-/Transportentfernungen und gemeinsame
Journey-Seeds wurden beobachtet. Der Gruppentest 02.11.2026 um 06/07 Uhr prüft
die tatsächlichen Reiseergebnisse; erfolgreicher Build ist keine Gameplay-Abnahme.
Build-/Testlogs: `checkpoints/2026-11-02-travel-costs/`.
+44 -73
View File
@@ -285,19 +285,9 @@ namespace VMAP
fclose(rf);
return LoadResult::FileNotFound;
}
else
{
std::string tilefile = basePath + getTileFileName(mapID, tileX, tileY);
FILE* tf = fopen(tilefile.c_str(), "rb");
if (!tf)
result = LoadResult::FileNotFound;
else
{
if (!readChunk(tf, chunk, VMAP_MAGIC, 8))
result = LoadResult::VersionMismatch;
fclose(tf);
}
}
if (!readChunk(tf, chunk, VMAP_MAGIC, 8))
result = LoadResult::VersionMismatch;
fclose(tf);
fclose(rf);
return result;
}
@@ -349,15 +339,13 @@ namespace VMAP
void StaticMapTree::UnloadMap(VMapManager2* vm)
{
for (loadedSpawnMap::iterator i = iLoadedSpawns.begin(); i != iLoadedSpawns.end(); ++i)
while (!iLoadedTiles.empty())
{
for (uint32 refCount = 0; refCount < i->second; ++refCount)
vm->releaseModelInstance(iTreeValues[i->first].getWorldModel()->GetName());
iTreeValues[i->first].setUnloaded();
uint32 tileX, tileY;
unpackTileID(iLoadedTiles.begin()->first, tileX, tileY);
UnloadMapTile(tileX, tileY, vm);
}
iLoadedSpawns.clear();
iLoadedTiles.clear();
}
//=========================================================
@@ -369,7 +357,13 @@ namespace VMAP
TC_LOG_ERROR("misc", "StaticMapTree::LoadMapTile() : tree has not been initialized [%u, %u]", tileX, tileY);
return LoadResult::ReadFromFileFailed;
}
uint32 tileID = packTileID(tileX, tileY);
auto loaded = iLoadedTiles.find(tileID);
if (loaded != iLoadedTiles.end())
return loaded->second.Result;
LoadResult result = LoadResult::FileNotFound;
LoadedTile tile;
TileFileOpenResult fileResult = OpenMapTileFile(iBasePath, iMapID, tileX, tileY, vm);
if (fileResult.File)
@@ -388,29 +382,38 @@ namespace VMAP
ModelSpawn spawn;
if (ModelSpawn::readFromFile(fileResult.File, spawn))
{
// acquire model instance
WorldModel* model = vm->acquireModelInstance(iBasePath, spawn.name, spawn.flags);
if (!model)
TC_LOG_ERROR("misc", "StaticMapTree::LoadMapTile() : could not acquire WorldModel pointer [%u, %u]", tileX, tileY);
// update tree
auto spawnIndex = iSpawnIndices.find(spawn.ID);
if (spawnIndex != iSpawnIndices.end())
{
uint32 referencedVal = spawnIndex->second;
if (referencedVal >= iNTreeValues)
{
TC_LOG_ERROR("maps", "StaticMapTree::LoadMapTile() : invalid tree element (%u/%u) referenced in tile %s", referencedVal, iNTreeValues, fileResult.Name.c_str());
continue;
}
WorldModel* model = vm->acquireModelInstance(iBasePath, spawn.name, spawn.flags);
if (!model)
{
TC_LOG_ERROR("misc", "StaticMapTree::LoadMapTile() : could not acquire WorldModel pointer [%u, %u]", tileX, tileY);
continue;
}
if (!iLoadedSpawns.count(referencedVal))
{
if (referencedVal >= iNTreeValues)
{
TC_LOG_ERROR("maps", "StaticMapTree::LoadMapTile() : invalid tree element (%u/%u) referenced in tile %s", referencedVal, iNTreeValues, fileResult.Name.c_str());
continue;
}
iTreeValues[referencedVal] = ModelInstance(spawn, model);
iLoadedSpawns[referencedVal] = 1;
}
else
{
if (iTreeValues[referencedVal].getWorldModel() != model)
{
TC_LOG_ERROR("maps", "StaticMapTree::LoadMapTile() : conflicting model for spawn %u in tile %s", spawn.ID, fileResult.Name.c_str());
vm->releaseModelInstance(spawn.name);
result = LoadResult::ReadFromFileFailed;
continue;
}
++iLoadedSpawns[referencedVal];
#ifdef VMAP_DEBUG
if (iTreeValues[referencedVal].ID != spawn.ID)
@@ -419,6 +422,7 @@ namespace VMAP
TC_LOG_DEBUG("maps", "StaticMapTree::LoadMapTile() : name collision on GUID=%u", spawn.ID);
#endif
}
tile.Models.push_back({ std::move(spawn.name), referencedVal });
}
else if (int32(iMapID) == fileResult.UsedMapId)
{
@@ -435,11 +439,10 @@ namespace VMAP
result = LoadResult::ReadFromFileFailed;
}
}
iLoadedTiles[packTileID(tileX, tileY)] = true;
fclose(fileResult.File);
}
else
iLoadedTiles[packTileID(tileX, tileY)] = false;
tile.Result = result;
iLoadedTiles.emplace(tileID, std::move(tile));
TC_METRIC_EVENT("map_events", "LoadMapTile",
"Map: " + std::to_string(iMapID) + " TileX: " + std::to_string(tileX) + " TileY: " + std::to_string(tileY));
return result;
@@ -456,49 +459,17 @@ namespace VMAP
VMAP_ERROR_LOG("misc", "StaticMapTree::UnloadMapTile() : trying to unload non-loaded tile - Map:%u X:%u Y:%u", iMapID, tileX, tileY);
return;
}
if (tile->second) // file associated with tile
for (LoadedModelReference const& model : tile->second.Models)
{
TileFileOpenResult fileResult = OpenMapTileFile(iBasePath, iMapID, tileX, tileY, vm);
if (fileResult.File)
auto referencedNode = iLoadedSpawns.find(model.TreeIndex);
if (referencedNode == iLoadedSpawns.end())
VMAP_ERROR_LOG("misc", "StaticMapTree::UnloadMapTile() : trying to unload non-referenced model '%s'", model.Name.c_str());
else if (--referencedNode->second == 0)
{
bool result=true;
char chunk[8];
if (!readChunk(fileResult.File, chunk, VMAP_MAGIC, 8))
result = false;
uint32 numSpawns;
if (fread(&numSpawns, sizeof(uint32), 1, fileResult.File) != 1)
result = false;
for (uint32 i=0; i<numSpawns && result; ++i)
{
// read model spawns
ModelSpawn spawn;
result = ModelSpawn::readFromFile(fileResult.File, spawn);
if (result)
{
// release model instance
vm->releaseModelInstance(spawn.name);
// update tree
auto spawnIndex = iSpawnIndices.find(spawn.ID);
if (spawnIndex == iSpawnIndices.end())
result = false;
else
{
uint32 referencedNode = spawnIndex->second;
if (!iLoadedSpawns.count(referencedNode))
VMAP_ERROR_LOG("misc", "StaticMapTree::UnloadMapTile() : trying to unload non-referenced model '%s' (ID:%u)", spawn.name.c_str(), spawn.ID);
else if (--iLoadedSpawns[referencedNode] == 0)
{
iTreeValues[referencedNode].setUnloaded();
iLoadedSpawns.erase(referencedNode);
}
}
}
else if (int32(iMapID) == fileResult.UsedMapId) // logic documented in StaticMapTree::LoadMapTile
result = false;
}
fclose(fileResult.File);
iTreeValues[model.TreeIndex].setUnloaded();
iLoadedSpawns.erase(referencedNode);
}
vm->releaseModelInstance(model.Name);
}
iLoadedTiles.erase(tile);
TC_METRIC_EVENT("map_events", "UnloadMapTile",
+13 -2
View File
@@ -48,7 +48,17 @@ namespace VMAP
class TC_COMMON_API StaticMapTree
{
typedef std::unordered_map<uint32, bool> loadedTileMap;
struct LoadedModelReference
{
std::string Name;
uint32 TreeIndex;
};
struct LoadedTile
{
LoadResult Result;
std::vector<LoadedModelReference> Models;
};
typedef std::unordered_map<uint32, LoadedTile> loadedTileMap;
typedef std::unordered_map<uint32, uint32> loadedSpawnMap;
private:
uint32 iMapID;
@@ -59,7 +69,8 @@ namespace VMAP
// Store all the map tile idents that are loaded for that map
// some maps are not splitted into tiles and we have to make sure, not removing the map before all tiles are removed
// empty tiles have no tile file, hence map with bool instead of just a set (consistency check)
// Keep precisely the references acquired by each tile. A failed load
// must never release another tile's or a dynamic object's model.
loadedTileMap iLoadedTiles;
std::vector<std::pair<int32, int32>> iLoadedPrimaryTiles;
// stores <tree_index, reference_count> to invalidate tree values, unload map, and to be able to report errors
@@ -18,11 +18,13 @@
#include "BattlefieldMgr.h"
#include "Containers.h"
#include "DatabaseEnv.h"
#include "DBCStores.h"
#include "Log.h"
#include "Map.h"
#include "ObjectMgr.h"
#include "Player.h"
#include "ScriptMgr.h"
#include "SpellAuraEffects.h"
namespace
{
@@ -141,6 +143,57 @@ bool BattlefieldMgr::IsWorldPvpArea(uint32 zoneId) const
return std::find(BattlefieldIdToZoneId.begin(), BattlefieldIdToZoneId.end(), zoneId) != BattlefieldIdToZoneId.end();
}
bool BattlefieldMgr::IsWintergraspFlightAllowed(uint32 mapId, uint32 areaId) const
{
// Cataclysm's static mount flags omit flight throughout Wintergrasp.
// The actual restriction is temporary and belongs to the battle state.
if (mapId != 571 || !DBCManager::IsInArea(areaId, 4197))
return false;
for (auto const& [map, battlefields] : _battlefieldsByMap)
if (map->GetId() == mapId)
for (auto const& battlefield : battlefields)
if (battlefield->GetZoneId() == 4197 && battlefield->IsEnabled() &&
!battlefield->CanFlyIn())
return false;
return true;
}
void BattlefieldMgr::UpdateWintergraspFlight(Player* player) const
{
// The client also needs this native mount-permission aura; server-only
// AreaTable exceptions do not enable every mount in the client UI.
constexpr uint32 flightPermission = 91262;
bool allowed = IsWintergraspFlightAllowed(player->GetMapId(), player->GetAreaId());
uint32 otherRestrictions = 0;
bool hasOtherRestrictions = false;
for (AuraEffect const* effect : player->GetAuraEffectsByType(SPELL_AURA_MOUNT_RESTRICTIONS))
if (effect->GetId() != flightPermission)
{
hasOtherRestrictions = true;
otherRestrictions |= uint32(effect->GetMiscValue());
}
if (hasOtherRestrictions && !(otherRestrictions & uint32(AreaMountFlags::AllowFlyingMounts)))
allowed = false;
if (allowed)
{
if (!player->HasAura(flightPermission))
player->AddAura(flightPermission, player);
}
else
player->RemoveAurasDueToSpell(flightPermission);
}
void BattlefieldMgr::UpdateWintergraspFlight(Map* map) const
{
for (auto const& reference : map->GetPlayers())
if (Player* player = reference.GetSource())
if (player->GetZoneId() == 4197)
{
UpdateWintergraspFlight(player);
player->UpdateMountCapability();
}
}
Battlefield* BattlefieldMgr::GetBattlefieldToZoneId(Map const* map, uint32 zoneId)
{
auto itr = _battlefieldsByZone.find({ map, zoneId });
@@ -52,6 +52,9 @@ class TC_GAME_API BattlefieldMgr
void HandlePlayerLeaveZone(Player* player, uint32 zoneId);
bool IsWorldPvpArea(uint32 zoneId) const;
bool IsWintergraspFlightAllowed(uint32 mapId, uint32 areaId) const;
void UpdateWintergraspFlight(Player* player) const;
void UpdateWintergraspFlight(Map* map) const;
// return assigned battlefield
Battlefield* GetBattlefieldToZoneId(Map const* map, uint32 zoneId);
@@ -7086,6 +7086,7 @@ void Player::UpdateArea(uint32 newArea)
else
RemoveRestFlag(REST_FLAG_IN_FACTION_AREA);
sBattlefieldMgr->UpdateWintergraspFlight(this);
UpdateMountCapability();
}
+4
View File
@@ -7911,7 +7911,11 @@ MountCapabilityEntry const* Unit::GetMountCapability(uint32 mountType) const
mountFlags |= AreaMountFlags(auraEffect->GetMiscValue());
}
else if (AreaTableEntry const* areaTable = sAreaTableStore.LookupEntry(areaId))
{
mountFlags = areaTable->GetMountFlags();
if (sBattlefieldMgr->IsWintergraspFlightAllowed(GetMapId(), areaId))
mountFlags |= AreaMountFlags::AllowFlyingMounts;
}
ZLiquidStatus liquidStatus = GetLiquidStatus();
isUnderwater = (liquidStatus & LIQUID_MAP_UNDER_WATER) != 0;
+5
View File
@@ -17,6 +17,7 @@
#include "SpellInfo.h"
#include "Battleground.h"
#include "BattlefieldMgr.h"
#include "Corpse.h"
#include "Creature.h"
#include "DBCStores.h"
@@ -1797,7 +1798,11 @@ SpellCastResult SpellInfo::CheckLocation(uint32 map_id, uint32 zone_id, uint32 a
mountFlags |= AreaMountFlags(auraEffect->GetMiscValue());
}
else if (AreaTableEntry const* areaTable = sAreaTableStore.LookupEntry(area_id))
{
mountFlags = areaTable->GetMountFlags();
if (sBattlefieldMgr->IsWintergraspFlightAllowed(map_id, area_id))
mountFlags |= AreaMountFlags::AllowFlyingMounts;
}
if (!(mountFlags.HasFlag(AreaMountFlags::AllowFlyingMounts)))
return SPELL_FAILED_INCORRECT_AREA;
+61 -5
View File
@@ -510,7 +510,11 @@ void AdventureBehaviorMgr::Begin(Player * player, Record & r)
if ((r.Intent=="instance" || r.Intent=="raid"))
{
if (!ResolveInstance(player,r)) {Fallback(player,r);return;}
Queue(player,r,"travel_to_location","id",l->BaseKey.empty()?r.Location:l->BaseKey,true);
// The entrance is a first-class TravelGraph transition. Route to its
// source node instead of an approximate catalog point; the native
// AreaTrigger radius remains the final arrival contract.
Queue(player,r,"travel_to_node","node",
"portal_at_"+std::to_string(r.Instance.Entry)+"_source",true);
}
else if (l->IsArea)
{
@@ -1619,11 +1623,36 @@ void AdventureBehaviorMgr::Update(Player * player, uint32 diff)
if (Busy(guid)) return;
auto status = sClientlessActionExecutorMgr->GetStatus(guid);
auto location = sAdventureLocationMgr->Find(r.Location);
bool arrived = status && status->State == ClientlessActionExecutionState::Completed && location && player->GetMapId() == location->Point.MapId;
if (arrived && location->IsArea) arrived = location->AreaId &&(player->GetAreaId() == location->AreaId || player->GetZoneId() == location->AreaId) && status->Detail != "best-effort travel completed at nearest useful reachable point";
else if (arrived) arrived = player->GetExactDist(location->Point.X, location->Point.Y, location->Point.Z) <= 6;
bool const completed=status && status->State==ClientlessActionExecutionState::Completed;
bool arrived=completed && location && player->GetMapId()==location->Point.MapId;
char const* arrivalContract="catalog-radius";
if (arrived && (r.Intent=="instance" || r.Intent=="raid") && r.Instance.Active)
{
arrivalContract="native-area-trigger";
auto entrance=sAreaTriggerStore.LookupEntry(r.Instance.Entry);
arrived=entrance && player->GetMapId()==uint32(entrance->ContinentID) &&
player->IsInAreaTriggerRadius(entrance);
}
else if (arrived && location->IsArea)
{
arrivalContract="area-or-zone";
arrived=location->AreaId && (player->GetAreaId()==location->AreaId ||
player->GetZoneId()==location->AreaId) &&
status->Detail!="best-effort travel completed at nearest useful reachable point";
}
else if (arrived)
arrived=player->GetExactDist(location->Point.X,location->Point.Y,location->Point.Z)<=6;
if (! arrived)
{
TC_LOG_WARN("server.adventurebot.navigation",
"INTENT_ARRIVAL_REJECT guid=%u intent='%s' location='%s' contract='%s' completed=%u status='%s' map=%u expectedMap=%u distance=%.2f entry=%u inTrigger=%u",
guid,r.Intent.c_str(),r.Location.c_str(),arrivalContract,completed?1u:0u,
status?status->Detail.c_str():"missing",player->GetMapId(),
location?location->Point.MapId:0u,
location?player->GetExactDist(location->Point.X,location->Point.Y,location->Point.Z):-1.0f,
r.Instance.Active?r.Instance.Entry:0u,
(r.Instance.Active && sAreaTriggerStore.LookupEntry(r.Instance.Entry) &&
player->IsInAreaTriggerRadius(sAreaTriggerStore.LookupEntry(r.Instance.Entry)))?1u:0u);
Fallback(player, r);
return;
}
@@ -1632,7 +1661,7 @@ void AdventureBehaviorMgr::Update(Player * player, uint32 diff)
r.State = Phase::OnSite;
r.Detail = "performing intent at destination";
sClientlessLuaEngine->UpdateWorldPresence(player,r.Intent,true);
TC_LOG_INFO("server.adventurebot", "INTENT_ARRIVED guid=%u intent='%s' location='%s'", guid, r.Intent.c_str(), r.Location.c_str());
TC_LOG_INFO("server.adventurebot", "INTENT_ARRIVED guid=%u intent='%s' location='%s' contract='%s'", guid, r.Intent.c_str(), r.Location.c_str(),arrivalContract);
}
if (r.State == Phase::OnSite)
{
@@ -2188,6 +2217,33 @@ bool AdventureBehaviorMgr::ProtectGroupLeave(Player* player) const
r.State==Phase::Handover;
}
bool AdventureBehaviorMgr::ProtectInstanceGroupLeave(Player* player) const
{
auto group=player->GetGroup();
if(!group)return false;
auto now=GameTime::GetGameTime();
for(auto reference=group->GetFirstMember();reference;reference=reference->next())
{
auto member=reference->GetSource();
if(!member || !member->IsInWorld())continue;
// The leader can already be waiting inside while late members travel.
// This also covers human leaders without an AdventureBot record.
if(member->GetMap()->IsDungeon())return true;
auto it=_records.find(member->GetGUID().GetCounter());
if(it==_records.end())continue;
auto const& r=it->second;
if(r.Instance.Active)
{
if(r.Instance.Stage!=AdventureInstancePolicy::Phase::Rest)return true;
continue;
}
if((r.Intent=="raid" || r.Intent=="instance") && r.Deadline>now)
if(auto location=sAdventureLocationMgr->Find(r.Location))
if(location->InstanceMap)return true;
}
return false;
}
void AdventureBehaviorMgr::InterruptForInteraction(Player* player)
{
InterruptForCombat(player);
@@ -43,6 +43,7 @@ public:
std::string InteractionIntent(Player* player) const;
std::string InteractionSchedule(Player* player);
bool ProtectGroupLeave(Player* player) const;
bool ProtectInstanceGroupLeave(Player* player) const;
private:
bool EditPlan(ChatHandler* handler, std::string const& args);
enum class Phase {Idle, Travel, OnSite, Switching, Handover, WaitingGroup};
@@ -344,9 +344,11 @@ bool AdventureGroupTravelMgr::Follow(Player* player)
{
auto flight=sClientlessFlightMgr->GetStatus(guid);
if(!flight || flight->State==ClientlessFlightState::Arrived || (!player->IsFlying() && flight->State==ClientlessFlightState::Failed))record.FlightOwned=false;
// Shared/action-owned corridors have their own validated destination.
// A leader landing ahead must not cancel a follower's catch-up flight.
else if(!flight->FollowingPlayer)return true;
else if(!player->IsInMap(leader) || !leader->IsFlying() || leader->IsInFlight() || leader->GetTransport() || Separated(player))
{sClientlessFlightMgr->LandFollowing(player);record.Detail="safe landing before ground regroup/leave";return true;}
else if(!flight->FollowingPlayer)return true;
}
if(player->GetTransport() || player->IsInFlight())return true;
if(!record.Pending.empty())
@@ -471,10 +473,25 @@ bool AdventureGroupTravelMgr::ValidateGroundPath(Player* player,std::vector<Posi
{
float t=float(step)/steps,x=a.GetPositionX()+(b.GetPositionX()-a.GetPositionX())*t,y=a.GetPositionY()+(b.GetPositionY()-a.GetPositionY())*t,z=a.GetPositionZ()+(b.GetPositionZ()-a.GetPositionZ())*t;
float floor=player->GetMap()->GetHeight(player->GetPhaseShift(),x,y,z+2,true,6);
if(floor<=INVALID_HEIGHT || !AdventureGroupTravelPolicy::GroundSample(z,floor,last,length/steps))return false;
if(floor<=INVALID_HEIGHT || !AdventureGroupTravelPolicy::GroundSample(z,floor,last,length/steps))
{
TC_LOG_WARN("server.adventurebot.navigation",
"GROUP_GROUND_REJECT guid=%u map=%u check='ground' segment=%u sample=%u/%u position=(%.3f %.3f %.3f) floor=%.3f previousFloor=%.3f heightError=%.3f floorStep=%.3f allowedHeightError=%.3f allowedFloorStep=%.3f",
player->GetGUID().GetCounter(),player->GetMapId(),uint32(i),step,steps,x,y,z,floor,last,
std::fabs(floor-z),std::fabs(floor-last),AdventureGroupTravelPolicy::MaxGroundStep,
AdventureGroupTravelPolicy::MaxGroundStep+AdventureGroupTravelPolicy::MaxGroundSlope*length/steps);
return false;
}
last=floor;
}
if(!player->GetMap()->isInLineOfSight(player->GetPhaseShift(),a.GetPositionX(),a.GetPositionY(),a.GetPositionZ()+1,b.GetPositionX(),b.GetPositionY(),b.GetPositionZ()+1,LINEOFSIGHT_ALL_CHECKS,VMAP::ModelIgnoreFlags(0)))return false;
if(!player->GetMap()->isInLineOfSight(player->GetPhaseShift(),a.GetPositionX(),a.GetPositionY(),a.GetPositionZ()+1,b.GetPositionX(),b.GetPositionY(),b.GetPositionZ()+1,LINEOFSIGHT_ALL_CHECKS,VMAP::ModelIgnoreFlags(0)))
{
TC_LOG_WARN("server.adventurebot.navigation",
"GROUP_GROUND_REJECT guid=%u map=%u check='line-of-sight' segment=%u from=(%.3f %.3f %.3f) to=(%.3f %.3f %.3f)",
player->GetGUID().GetCounter(),player->GetMapId(),uint32(i),a.GetPositionX(),a.GetPositionY(),
a.GetPositionZ()+1,b.GetPositionX(),b.GetPositionY(),b.GetPositionZ()+1);
return false;
}
}
return true;
}
@@ -179,6 +179,8 @@ bool AdventureInteractionMgr::Update(Player* p,uint32 diff)
if(protectedFormation){r.LastActivity=now;r.AfkSince=0;r.Schedule=sAdventureBehaviorMgr->InteractionSchedule(p);}
bool protectedTravel=sAdventureGroupTravelMgr->Protected(p) || p->isUsingLfg() || p->InBattlegroundQueue() || p->InBattleground();
if(protectedTravel){r.LastActivity=now;r.AfkSince=0;}
bool protectedInstance=sAdventureBehaviorMgr->ProtectInstanceGroupLeave(p);
if(protectedInstance){r.LastActivity=now;r.AfkSince=0;}
if(!protectedFormation && !protectedTravel && AdventureInteractionPolicy::CanLeave(p->IsAlive(),p->IsInCombat(),p->GetMap()->IsDungeon(),p->GetTransport() || p->IsInFlight() || p->IsFlying()))
{
if(r.Schedule!=sAdventureBehaviorMgr->InteractionSchedule(p))
@@ -186,8 +188,8 @@ bool AdventureInteractionMgr::Update(Player* p,uint32 diff)
r.Schedule=sAdventureBehaviorMgr->InteractionSchedule(p);
if(!sAdventureGroupMgr->PlannedTransition(p) && frand(0,1)>=Loyalty(p)){Leave(p,r,"intent-change");return false;}
}
if(sAdventureGroupTravelMgr->Separated(p)){Leave(p,r,"leader-distance");return false;}
if(AdventureInteractionPolicy::LeaderExpired(now,r.AfkSince?r.AfkSince:r.LastActivity)){Leave(p,r,"leader-afk");return false;}
if(!protectedInstance && sAdventureGroupTravelMgr->Separated(p)){Leave(p,r,"leader-distance");return false;}
if(!protectedInstance && AdventureInteractionPolicy::LeaderExpired(now,r.AfkSince?r.AfkSince:r.LastActivity)){Leave(p,r,"leader-afk");return false;}
}
// Existing warrior executor supplies damage assistance. Roles/healing
// deliberately remain a later profile-driven extension.
@@ -214,6 +214,35 @@ bool ClientlessActionExecutorMgr::OwnsActiveFlight(uint32 characterGuid) const
bool ClientlessActionExecutorMgr::StartAction(Player* player, ClientlessAction const& action, Record& record)
{
if(action.Name=="travel_to_node" || action.Name=="travel_to_location")
{
auto goal=action.Parameters.find(action.Name=="travel_to_node"?"node":"id");
std::string key=action.Name+":"+(goal==action.Parameters.end()?std::string():goal->second);
// Inherit preferences even while the leader's route is still being
// published. The shared-route timeout must not roll a follower's own
// modal preference before a local catch-up plan.
ClientlessTravelCost::Profile const* inherited=nullptr;
auto requested=action.Parameters.find("route_source");
auto group=player->GetGroup();
if(requested!=action.Parameters.end() && group)
{
uint32 sourceGuid=uint32(std::strtoul(requested->second.c_str(),nullptr,10));
auto source=_records.find(sourceGuid);
if(group->GetLeaderGUID().GetCounter()==sourceGuid && sourceGuid!=player->GetGUID().GetCounter() &&
source!=_records.end() && source->second.HasJourneyCosts && source->second.JourneyCostKey==key)
inherited=&source->second.JourneyCosts;
}
if(inherited || !record.HasJourneyCosts || record.JourneyCostKey!=key || record.Status.State==ClientlessActionExecutionState::Completed)
{
record.JourneyCosts=inherited?*inherited:sClientlessTravelGraphMgr->NewJourneyCosts();
record.JourneyCostKey=key;record.HasJourneyCosts=true;
TC_LOG_INFO("server.adventurebot.navigation",
"TRAVEL_JOURNEY_COSTS guid=%u seed=%u goal='%s' ground=%.3f taxi=%.3f flight=%.3f ship=%.3f zeppelin=%.3f tram=%.3f portal=%.3f",
player->GetGUID().GetCounter(),record.JourneyCosts.Seed,key.c_str(),
record.JourneyCosts.Factors[0],record.JourneyCosts.Factors[1],record.JourneyCosts.Factors[2],
record.JourneyCosts.Factors[3],record.JourneyCosts.Factors[4],record.JourneyCosts.Factors[5],record.JourneyCosts.Factors[6]);
}
}
record.StopAtNode = false;record.FormationWaiting=false;record.GroundChunk=false;
record.TransportActive = false;
record.FlightActive = false;
@@ -635,7 +664,7 @@ bool ClientlessActionExecutorMgr::StartTravelToNode(
if (!sClientlessTravelGraphMgr->PlanAdaptiveRoute(
player, destinationNode, startRadius, record.LocalBridgeRadius,
record.Route, record.RouteComplete, record.AdaptiveReachedNode, error,
&record.FailedEdgeDatabaseIds, &record.FailedBridgeKeys))
&record.FailedEdgeDatabaseIds, &record.FailedBridgeKeys, nullptr, &record.JourneyCosts))
{
TC_LOG_WARN("server.adventurebot.navigation",
"TRAVEL_PLAN_FAILED guid=%u name='%s' action='travel_to_node' destination='%s' reason='%s'",
@@ -827,7 +856,7 @@ bool ClientlessActionExecutorMgr::StartTravelToLocation(
if (!sClientlessTravelGraphMgr->PlanAdaptiveRoute(
player, anchorNode, startRadius, record.LocalBridgeRadius,
record.Route, record.RouteComplete, record.AdaptiveReachedNode, error,
&record.FailedEdgeDatabaseIds, &record.FailedBridgeKeys, &goal))
&record.FailedEdgeDatabaseIds, &record.FailedBridgeKeys, &goal, &record.JourneyCosts))
{
TC_LOG_WARN("server.adventurebot.navigation",
"C820_LOCATION_PLAN_FAILED location='%s' reason='%s'",
@@ -990,6 +1019,12 @@ bool ClientlessActionExecutorMgr::StartRouteStep(
}
if(!access)
{
TC_LOG_WARN("server.adventurebot.navigation",
"ROUTE_ACCESS_DENIED guid=%u name='%s' step=%u/%u node='%s' edgeType='%s' "
"edgeDbId=%u nodeAllowed=%u",
characterGuid, player->GetName().c_str(), uint32(record.RouteStep + 1),
uint32(record.Route.size()), step.NodeKey.c_str(), step.EdgeType.c_str(),
step.EdgeDatabaseId, accessNode && sAdventureAreaMgr->AllowsNode(player,*accessNode) ? 1u : 0u);
record.Status.State=ClientlessActionExecutionState::Failed;
record.Status.Detail="route denied by area/node catalog";record.Active=false;
return false;
@@ -1528,6 +1563,8 @@ bool ClientlessActionExecutorMgr::StartRouteStep(
portalSourceApproach))
{
record.Status.State = ClientlessActionExecutionState::Failed;
if (TryRouteApproachFlight(player, characterGuid, record))
return true;
record.Status.Detail = "travel start failed for route node " + step.NodeKey;
record.Active = false;
return false;
@@ -1583,7 +1620,11 @@ bool ClientlessActionExecutorMgr::StartPortalUse(Player* player,
record.Status.Detail = reason;
return false;
};
if(portal && !sClientlessTravelGraphMgr->PortalPrerequisites(player,step.EdgeDatabaseId))
bool const nativePrerequisites = portal &&
sClientlessTravelGraphMgr->PortalNativePrerequisites(player,step.EdgeDatabaseId);
bool const adventureUnlock = portal && !nativePrerequisites &&
sClientlessTravelGraphMgr->AdventurePortalUnlockAllowed(player,step.EdgeDatabaseId);
if(portal && !nativePrerequisites && !adventureUnlock)
return fail("native portal prerequisites not satisfied");
if (!portal || !record.RouteStep || step.EdgeReverse ||
player->GetMapId() != portal->Source.MapId || !player->IsAlive() ||
@@ -1626,11 +1667,27 @@ bool ClientlessActionExecutorMgr::StartPortalUse(Player* player,
TC_LOG_INFO("server.adventurebot.navigation", "PORTAL_USE_START guid=%u edge=%u spawn=%u entry=%u spell=%u sourceMap=%u targetMap=%u",
characterGuid, step.EdgeDatabaseId, portal->SpawnId, portal->Entry, portal->SpellId,
portal->Source.MapId, step.MapId);
// Same packet handler as a player's click. Native Use/AI, party checks,
// spell cast restrictions and scripted conditions remain authoritative.
WorldPacket usePacket(CMSG_GAMEOBJ_USE, 8);
usePacket << object->GetGUID();
player->GetSession()->HandleGameObjectUseOpcode(usePacket);
if (adventureUnlock)
{
// These performative bots never complete quests. Reproduce the fixed,
// importer-validated destination of the bound public portal while the
// normal executor still verifies source object, range, faction/area
// policy and the resulting teleport sequence/arrival coordinates.
TC_LOG_INFO("server.adventurebot.navigation",
"PORTAL_ADVENTURE_UNLOCK guid=%u level=%u edge=%u spawn=%u",
characterGuid, player->getLevel(), step.EdgeDatabaseId, portal->SpawnId);
if (!player->TeleportTo(portal->Target.MapId, portal->Target.X,
portal->Target.Y, portal->Target.Z, portal->Target.O))
return fail("adventure portal unlock teleport failed");
}
else
{
// Same packet handler as a player's click. Native Use/AI, party checks,
// spell cast restrictions and scripted conditions remain authoritative.
WorldPacket usePacket(CMSG_GAMEOBJ_USE, 8);
usePacket << object->GetGUID();
player->GetSession()->HandleGameObjectUseOpcode(usePacket);
}
return true;
}
@@ -1828,8 +1885,11 @@ bool ClientlessActionExecutorMgr::StartRecordedEdgePath(
size_t original=path.size();
if(!adapter->second(player,path))
{
player->StopMoving();record.Active=false;record.Status.State=ClientlessActionExecutionState::Failed;
record.Status.Detail="recorded group road rejected by safety adapter";return false;
player->StopMoving();
uint32 const failedEdge = record.RouteStep < record.Route.size()
? record.Route[record.RouteStep].EdgeDatabaseId : 0;
return RecoverTravel(player,characterGuid,record,failedEdge,
"recorded group road rejected by safety adapter");
}
if(path.size()<2)
{if(player->IsSplineEnabled())player->StopMoving();record.FormationWaiting=true;record.Status.State=ClientlessActionExecutionState::Moving;record.Status.Detail="waiting for leading formation row";return true;}
@@ -2198,6 +2258,42 @@ bool ClientlessActionExecutorMgr::TryRescueTravel(Player* player,uint32 characte
return StartRouteStep(player,characterGuid,record);
}
bool ClientlessActionExecutorMgr::TryRouteApproachFlight(Player* player, uint32 characterGuid, Record& record)
{
if (record.RouteStep >= record.Route.size())
return false;
auto const& step = record.Route[record.RouteStep];
// A local connector may be flown without changing the group's macro route.
// Recorded roads and transport edges retain their own execution semantics.
if (step.MapId != player->GetMapId() || step.EdgeDatabaseId ||
(step.EdgeType != "start" && step.EdgeType != "local_bridge") ||
player->GetExactDist(step.X, step.Y, step.Z) < 5.0f)
return false;
std::string const key = "approach-flight:" + step.NodeKey;
if (!record.FailedBridgeKeys.insert(key).second)
return false;
Position target;
target.Relocate(step.X, step.Y, step.Z, step.O);
std::string error;
// Start validates native flying permissions, collision, solid landing and
// an MMAP connection from the landing to the actual (possibly indoor) node.
if (!sClientlessFlightMgr->Start(player, target, error, true))
{
TC_LOG_INFO("server.adventurebot.navigation", "ROUTE_APPROACH_FLIGHT_REJECT guid=%u node='%s' reason='%s'",
characterGuid, step.NodeKey.c_str(), error.c_str());
return false;
}
record.DeferredStartActive = false;
record.EdgePath.clear();
record.FlightActive = true;
record.Active = true;
record.Status.State = ClientlessActionExecutionState::Moving;
record.Status.Detail = "flying to existing route node after rejected ground approach";
TC_LOG_INFO("server.adventurebot.navigation", "ROUTE_APPROACH_FLIGHT guid=%u node='%s' step=%u",
characterGuid, step.NodeKey.c_str(), uint32(record.RouteStep));
return true;
}
bool ClientlessActionExecutorMgr::RecoverTravel(
Player* player,
uint32 characterGuid,
@@ -2210,10 +2306,20 @@ bool ClientlessActionExecutorMgr::RecoverTravel(
if (!player || !player->IsInWorld())
return false;
if (reason.rfind("route-local-", 0) == 0 && TryRouteApproachFlight(player, characterGuid, record))
return true;
if(record.RescueActive)
{record.RescueActive=false;record.RescueWalkFailed=true;}
if (failedEdgeDatabaseId)
record.FailedEdgeDatabaseIds.insert(failedEdgeDatabaseId);
// StartTravel can accept a request whose first spline is subsequently
// rejected by the owner validator. Remember dynamic bridges here as well.
if (!failedEdgeDatabaseId && reason.rfind("route-local-", 0) == 0 &&
record.RouteStep > 0 && record.RouteStep < record.Route.size() &&
record.Route[record.RouteStep].EdgeType == "local_bridge")
record.FailedBridgeKeys.insert(record.Route[record.RouteStep - 1].NodeKey +
"->" + record.Route[record.RouteStep].NodeKey);
// A failed initial local approach must not be tried five times at the same spot.
if (!failedEdgeDatabaseId && record.RouteStep == 0 && !record.Route.empty() &&
reason.rfind("route-local-", 0) == 0)
@@ -2309,7 +2415,7 @@ bool ClientlessActionExecutorMgr::RecoverTravel(
planned = sClientlessTravelGraphMgr->PlanAdaptiveRoute(
player, anchorNode, radius, record.LocalBridgeRadius,
record.Route, record.RouteComplete, record.AdaptiveReachedNode, error,
&record.FailedEdgeDatabaseIds, &record.FailedBridgeKeys, &goal);
&record.FailedEdgeDatabaseIds, &record.FailedBridgeKeys, &goal, &record.JourneyCosts);
}
else
error = "no graph node exists on destination map";
@@ -2325,7 +2431,7 @@ bool ClientlessActionExecutorMgr::RecoverTravel(
planned = sClientlessTravelGraphMgr->PlanAdaptiveRoute(
player, record.PlannedDestinationNode, radius, record.LocalBridgeRadius,
record.Route, record.RouteComplete, record.AdaptiveReachedNode, error,
&record.FailedEdgeDatabaseIds, &record.FailedBridgeKeys);
&record.FailedEdgeDatabaseIds, &record.FailedBridgeKeys, nullptr, &record.JourneyCosts);
if (planned)
TC_LOG_WARN("server.adventurebot.navigation",
@@ -2664,7 +2770,10 @@ void ClientlessActionExecutorMgr::UpdateTravelToNode(
float dz = player->GetPositionZ() - destination.Z;
float remaining = std::sqrt(dx * dx + dy * dy + dz * dz);
if (remaining > 2.0f)
bool const portalApproach = record.RouteStep + 1 < record.Route.size() &&
record.Route[record.RouteStep + 1].EdgeType == "portal_use";
float const arrivalTolerance = portalApproach ? 5.0f : 2.0f;
if (remaining > arrivalTolerance)
{
uint32 const failedEdgeId =
record.RouteStep < record.Route.size()
@@ -3005,6 +3114,11 @@ bool ClientlessActionExecutorMgr::AdoptRoute(Player* player,ClientlessAction con
auto const snapshot=source->second;
auto goal=action.Parameters.find(action.Name=="travel_to_location"?"id":"node");
if(goal==action.Parameters.end() || goal->second!=(action.Name=="travel_to_location"?snapshot.FinalLocationId:snapshot.PlannedDestinationNode))return false;
// Catch-up planning and later recoveries share the leader's sampled modal
// costs. A follower must never choose a new transport preference here.
record.JourneyCosts=snapshot.JourneyCosts;
record.JourneyCostKey=snapshot.JourneyCostKey;
record.HasJourneyCosts=snapshot.HasJourneyCosts;
auto start=std::min(snapshot.RouteStep?snapshot.RouteStep-1:0,route.size()-1);
auto anchor=route[start];
std::vector<ClientlessTravelRouteStep> joined;
@@ -3014,7 +3128,7 @@ bool ClientlessActionExecutorMgr::AdoptRoute(Player* player,ClientlessAction con
else
{
bool complete=false;std::string reached,error;
if(!sClientlessTravelGraphMgr->PlanAdaptiveRoute(player,anchor.NodeKey,500,300,joined,complete,reached,error) || !complete)return false;
if(!sClientlessTravelGraphMgr->PlanAdaptiveRoute(player,anchor.NodeKey,500,300,joined,complete,reached,error,nullptr,nullptr,nullptr,&record.JourneyCosts) || !complete)return false;
}
joined.insert(joined.end(),route.begin()+start+1,route.end());
record.Route=std::move(joined);record.RouteStep=0;
@@ -3024,6 +3138,6 @@ bool ClientlessActionExecutorMgr::AdoptRoute(Player* player,ClientlessAction con
record.FinalDatabaseId=snapshot.FinalDatabaseId;record.PlannedDestinationNode=snapshot.PlannedDestinationNode;
record.RouteComplete=snapshot.RouteComplete;record.AdaptiveReachedNode=snapshot.AdaptiveReachedNode;
record.HardDestination=snapshot.HardDestination;record.Active=true;
TC_LOG_INFO("server.adventurebot.navigation","TRAVEL_SHARED_ROUTE guid=%u leader=%u steps=%u",player->GetGUID().GetCounter(),sourceGuid,uint32(record.Route.size()));
TC_LOG_INFO("server.adventurebot.navigation","TRAVEL_SHARED_ROUTE guid=%u leader=%u steps=%u journeySeed=%u",player->GetGUID().GetCounter(),sourceGuid,uint32(record.Route.size()),record.JourneyCosts.Seed);
return StartRouteStep(player,player->GetGUID().GetCounter(),record);
}
@@ -57,6 +57,9 @@ public:
private:
struct Record
{
ClientlessTravelCost::Profile JourneyCosts;
std::string JourneyCostKey;
bool HasJourneyCosts = false;
ClientlessActionExecutionStatus Status;
bool Active = false;
std::shared_ptr<ClientlessAction> PendingShared;
@@ -140,6 +143,7 @@ private:
bool StartRecordedEdgePath(Player* player, uint32 characterGuid, Record& record);
bool GetTravelGoal(Record const& record, ClientlessTravelNode& goal) const;
bool TryFallbackFlight(Player* player, uint32 characterGuid, Record& record);
bool TryRouteApproachFlight(Player* player, uint32 characterGuid, Record& record);
bool TryRescueTravel(Player* player, uint32 characterGuid, Record& record);
void CheckGroundExit(Player* player, uint32 characterGuid, Record& record);
void UpdateGroundExit(Player* player, uint32 characterGuid, Record& record);
+150 -21
View File
@@ -66,6 +66,18 @@ namespace
if(!ClearRay(player,{x+offset.X,y+offset.Y,surface.Ground+0.8f},{x+offset.X,y+offset.Y,top}))return false;
return true;
}
bool OpenColumnFrom(Player* player,float x,float y,float z,float height)
{
// A height query from MAX_HEIGHT can legitimately return a bridge,
// tree canopy or other collision surface far above the player. Such a
// surface is useful as an obstacle, but it is not the player's support
// floor. Test the actual take-off column from the player's feet.
if (!std::isfinite(z) || height < 5.0f) return false;
for (auto offset : std::array<Point,5>{{{0,0,0},{2,0,0},{-2,0,0},{0,2,0},{0,-2,0}}})
if (!ClearRay(player,{x+offset.X,y+offset.Y,z+0.8f},
{x+offset.X,y+offset.Y,z+height})) return false;
return true;
}
char const * StateName(ClientlessFlightState state)
{
switch (state)
@@ -147,7 +159,10 @@ float ClientlessFlightMgr::Estimate(Player const * player, uint32 map, Position
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));
bool const wintergrasp = map == 571 && DBCManager::IsInArea(area, 4197);
bool const wintergraspFlight = sBattlefieldMgr->IsWintergraspFlightAllowed(map, area);
if (wintergrasp && !wintergraspFlight) return Infinity();
bool const dynamicRules = wintergraspFlight || 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
@@ -196,9 +211,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<float>::infinity()
};
}
@@ -273,16 +291,32 @@ bool ClientlessFlightMgr::Follow(Player* player,Position const& target,std::stri
bool ClientlessFlightMgr::LandFollowing(Player* player)
{
auto found=_records.find(player->GetGUID().GetCounter());if(found==_records.end())return false;
auto& record=found->second;if(!record.Status.FollowingPlayer)return false;
auto& record=found->second;
bool const shared=bool(record.Following);
if(!record.Status.FollowingPlayer && !shared)return false;
record.Following.reset();record.SharedWaitSince=0;
record.Published->Aborted=true;
record.Status.FollowingPlayer=false;
if(record.Status.State==ClientlessFlightState::Mounting)
{Fail(player,record,"group flight cancelled before takeoff");return true;}
auto surface=Sample(player,record,player->GetPositionX(),player->GetPositionY());
float solid=player->GetMap()->GetHeight(player->GetPhaseShift(),player->GetPositionX(),player->GetPositionY(),MAX_HEIGHT,true,MAX_HEIGHT);
if(surface.Valid && std::fabs(surface.Ground-solid)<0.7f)
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);};
Point landing{player->GetPositionX(),player->GetPositionY(),solid};
if(surface.Valid && std::fabs(surface.Ground-solid)<0.7f &&
solid<=player->GetPositionZ()+0.7f &&
Corridor(PointOf(*player),landing,landing,record.Settings,sample,ray,true))
{
record.Status.Landing.Relocate(player->GetPositionX(),player->GetPositionY(),solid,player->GetOrientation());
record.Status.Target=record.Status.Landing;record.Memory={};record.Following.reset();record.Status.Travelled=0;
player->StopMoving();record.Moving=false;
if(shared)
{
// This is a regroup landing, not arrival at the requested raid.
record.Start=record.Status.Landing;record.Returning=true;
record.AbortReason="shared flight regroup landing";record.Breadcrumbs.clear();
}
record.Status.Detail="landing after group flight";return true;
}
Fail(player,record,"no safe local landing; returning along validated flight trail");return true;
@@ -318,6 +352,19 @@ bool ClientlessFlightMgr::AdvanceFollowing(Player* player,Record& record)
record.Moving=true;record.SegmentElapsed=0;++record.Status.Segments;record.Status.Detail="following moving group flight target";return true;
}
void ClientlessFlightMgr::Store(uint32 guid, Record&& record)
{
auto old=_records.find(guid);
if(old!=_records.end() && old->second.Published!=record.Published)
{
old->second.Published->Aborted=true;
if(old->second.Status.MapId==record.Status.MapId &&
old->second.Status.Target.GetExactDist(record.Status.Target)<1)
old->second.Published->Replacement=record.Published;
}
_records[guid]=std::move(record);
}
bool ClientlessFlightMgr::Begin(Player * player, Position const & target, std::string & error, bool following)
{
if (! Trinity::IsValidMapCoord(target.GetPositionX(), target.GetPositionY(), target.GetPositionZ()))
@@ -398,13 +445,30 @@ bool ClientlessFlightMgr::Begin(Player * player, Position const & target, std::s
bool const airborne=existingMount && origin.Valid && player->GetPositionZ()>=origin.Ground+0.7f &&
player->GetPositionZ()+5<=origin.Ceiling && ClearRay(player,PointOf(*player),{player->GetPositionX(),player->GetPositionY(),player->GetPositionZ()+5});
float const feet=player->GetMap()->GetHeight(player->GetPhaseShift(),player->GetPositionX(),player->GetPositionY(),player->GetPositionZ()+2,true,6);
if (!origin.Valid || (!airborne && (!std::isfinite(feet) || feet<=INVALID_HEIGHT+1 || std::fabs(origin.Ground-player->GetPositionZ())>3 || std::fabs(feet-player->GetPositionZ())>3)))
bool const localSupport=std::isfinite(feet) && feet>INVALID_HEIGHT+1 &&
std::fabs(feet-player->GetPositionZ())<=3;
bool const overheadSurface=origin.Valid && origin.Ground>player->GetPositionZ()+3;
bool const openFromFeet=overheadSurface && OpenColumnFrom(player,player->GetPositionX(),
player->GetPositionY(),player->GetPositionZ(),
std::min(record.Settings.Clearance,origin.Ground-player->GetPositionZ()-1.0f));
// Not falling/in water was verified above. If the global height sample
// selected a remote surface above us, a clear local vertical column is a
// valid departure even though that remote surface cannot be our floor.
bool const supportedDeparture=localSupport || openFromFeet;
if (!origin.Valid || (!airborne && !supportedDeparture))
{
if(std::isfinite(feet) && feet>INVALID_HEIGHT+1 && std::fabs(feet-player->GetPositionZ())<=3 && ApproachDeparture(player,record,following))return true;
if(localSupport && ApproachDeparture(player,record,following))return true;
error = "departure-unavailable: no supported open takeoff point";
TC_LOG_WARN("server.adventurebot.navigation","FLIGHT_DEPARTURE_REJECT guid=%u z=%.3f surface=%.3f feet=%.3f valid=%u mounted=%u canFly=%u",player->GetGUID().GetCounter(),player->GetPositionZ(),origin.Ground,feet,origin.Valid?1:0,player->IsMounted()?1:0,player->CanFly()?1:0);
TC_LOG_WARN("server.adventurebot.navigation","FLIGHT_DEPARTURE_REJECT guid=%u z=%.3f surface=%.3f feet=%.3f valid=%u localSupport=%u overhead=%u openFromFeet=%u mounted=%u canFly=%u",
player->GetGUID().GetCounter(),player->GetPositionZ(),origin.Ground,feet,
origin.Valid?1:0,localSupport?1:0,overheadSurface?1:0,openFromFeet?1:0,
player->IsMounted()?1:0,player->CanFly()?1:0);
return false;
}
if(openFromFeet)
ClientlessTravelTelemetry::Note(player,"flight-departure-overhead-surface",
"z="+std::to_string(player->GetPositionZ())+" surface="+std::to_string(origin.Ground)+
" feet="+std::to_string(feet));
bool foundLanding = false;
std::array<uint32,5> landingRejected{}; // surface, water, column, MMAP, area
auto landingSummary=[&]()
@@ -467,17 +531,22 @@ bool ClientlessFlightMgr::Begin(Player * player, Position const & target, std::s
record.Status.State = ClientlessFlightState::Mounting;
record.Status.MountSpell = existingMount ? existingMount : spell;
record.Status.Detail = "waiting for native flying mount";
record.Published->Landing=PointOf(record.Status.Landing);
record.Published->LandingReady=true;
if(!following)if(auto group=player->GetGroup())if(group->GetLeaderGUID()!=player->GetGUID())
{
auto source=_records.find(group->GetLeaderGUID().GetCounter());
if(source!=_records.end() && source->second.Status.MapId==player->GetMapId() &&
source->second.Status.Target.GetExactDist(target)<1 && !source->second.Published->Aborted)
{
record.Following=source->second.Published;record.Status.Landing=source->second.Status.Landing;
// The leader may still be walking to takeoff and have no landing
// yet. Its published trail survives that preparation. Bind the
// verified landing lazily rather than copying an unset Position.
record.Following=source->second.Published;
std::vector<ObjectGuid> members;for(auto const& member:group->GetMemberSlots())if(member.guid!=group->GetLeaderGUID())members.push_back(member.guid);
std::sort(members.begin(),members.end());auto slot=std::find(members.begin(),members.end(),player->GetGUID())-members.begin();
record.FormationDelay=3500+uint32(slot)*600; // native shared corridor, staggered single-file departure
TC_LOG_INFO("server.adventurebot.navigation","FLIGHT_SHARED_TRAIL guid=%u leader=%u",player->GetGUID().GetCounter(),group->GetLeaderGUID().GetCounter());
TC_LOG_INFO("server.adventurebot.navigation","FLIGHT_SHARED_TRAIL guid=%u leader=%u landingReady=%u",player->GetGUID().GetCounter(),group->GetLeaderGUID().GetCounter(),record.Following->LandingReady?1u:0u);
}
}
if(airborne)
@@ -489,9 +558,9 @@ bool ClientlessFlightMgr::Begin(Player * player, Position const & target, std::s
record.Memory.Departed=player->GetPositionZ()-origin.Ground>record.Settings.Safety;
player->AddUnitMovementFlag(MOVEMENTFLAG_FLYING);
TC_LOG_INFO("server.adventurebot.navigation","FLIGHT_RESUME_AIRBORNE guid=%u ground=%.3f z=%.3f",player->GetGUID().GetCounter(),origin.Ground,player->GetPositionZ());
_records[player->GetGUID().GetCounter()]=std::move(record);return true;
Store(player->GetGUID().GetCounter(),std::move(record));return true;
}
_records[player->GetGUID().GetCounter()] = std::move(record);
Store(player->GetGUID().GetCounter(),std::move(record));
if (!existingMount && player->CastSpell(player, spell, false) != SPELL_CAST_OK)
{
Fail(player, _records.at(player->GetGUID().GetCounter()), "native mount spell rejected");
@@ -557,7 +626,7 @@ bool ClientlessFlightMgr::ApproachDeparture(Player* player, Record& record, bool
record.Status.State=ClientlessFlightState::Mounting;record.Status.FollowingPlayer=following;
record.Status.Detail="walking to an open takeoff point";
TC_LOG_INFO("server.adventurebot.navigation","FLIGHT_DEPARTURE_WALK guid=%u destination=(%.3f %.3f %.3f) path=%.2f",player->GetGUID().GetCounter(),x,y,surface.Ground,path.PathLength);
_records[player->GetGUID().GetCounter()]=std::move(record);return true;
Store(player->GetGUID().GetCounter(),std::move(record));return true;
}
summary();return false;
}
@@ -593,6 +662,26 @@ void ClientlessFlightMgr::Fail(Player * player, Record & record, std::string con
bool ClientlessFlightMgr::Advance(Player * player, Record & record, bool handoff)
{
if(record.Following && !record.Returning)
{
while(record.Following->Replacement)
{
record.Following=record.Following->Replacement;
record.SharedIndex=0;record.Join={};record.SharedWaitSince=0;
}
if(record.Following->Aborted)
{LandFollowing(player);return true;}
Point landing;
if(!record.Following->BindLanding(landing))
{
if(!record.SharedWaitSince)record.SharedWaitSince=record.Elapsed+1;
if(record.Following->Complete || record.Elapsed+1-record.SharedWaitSince>=120000)
{LandFollowing(player);return true;}
record.Status.Detail="waiting for leader validated landing";return true;
}
record.Status.Landing.Relocate(landing.X,landing.Y,landing.Z);
record.Published->Landing=landing;record.Published->LandingReady=true;
}
Point current = PointOf(* player), goal = PointOf(record.Returning ? record.Start : record.Status.Landing);
auto sample =[&](float x, float y)
{
@@ -618,6 +707,7 @@ bool ClientlessFlightMgr::Advance(Player * player, Record & record, bool handoff
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";
record.Published->Complete=true;
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;
}
@@ -633,9 +723,21 @@ bool ClientlessFlightMgr::Advance(Player * player, Record & record, bool handoff
}
else if(record.Following)
{
auto shared=FollowTrail(current,goal,record.Settings,*record.Following,record.SharedIndex,sample,ray);
if(shared.Waiting)return true;
auto shared=FollowTrail(current,goal,record.Settings,*record.Following,record.SharedIndex,sample,ray,
handoff?player->GetSpeed(MOVE_FLIGHT)*1.5f:0.f,&record.Join);
if(shared.Waiting)
{
if(!record.SharedWaitSince)record.SharedWaitSince=record.Elapsed+1;
if(record.Following->Complete || record.Elapsed+1-record.SharedWaitSince>=120000)
LandFollowing(player);
return true;
}
record.SharedWaitSince=0;
next=shared.Step;
if(!next.Valid)
TC_LOG_WARN("server.adventurebot.navigation","FLIGHT_SHARED_REJECT guid=%u index=%u size=%u aborted=%u landing=(%.3f %.3f %.3f)",
player->GetGUID().GetCounter(),uint32(record.SharedIndex),uint32(record.Following->Points.size()),record.Following->Aborted?1u:0u,goal.X,goal.Y,goal.Z);
if(!next.Valid){LandFollowing(player);return true;}
if(handoff && next.Valid && Distance(next.Next,PointOf(record.Segment))<1)return true;
}
else
@@ -678,19 +780,26 @@ bool ClientlessFlightMgr::Advance(Player * player, Record & record, bool handoff
init.SetFly();
init.SetWalk(false);
init.SetVelocity(player->GetSpeed(MOVE_FLIGHT));
if (init.Launch() <= 0)
int32 const duration = init.Launch();
if (duration <= 0)
{
Fail(player, record, "flight spline rejected");
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.Progress.Begin(Distance(current, next.Next), uint32(duration));
++ 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";
@@ -730,6 +839,8 @@ void ClientlessFlightMgr::Update(uint32 diff)
// 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::Flying && record.Moving)
record.Progress.Observe(Distance(PointOf(*player), PointOf(record.Segment)), diff);
if (record.Status.State == ClientlessFlightState::Mounting)
{
if (player->IsMounted() && player->CanFly()) record.Status.State = ClientlessFlightState::Flying;
@@ -754,7 +865,7 @@ void ClientlessFlightMgr::Update(uint32 diff)
player->StopMoving();record.Moving=false;record.SegmentElapsed=0;
record.Start.Relocate(player);record.LastPosition.Relocate(player);
record.Memory={};record.Breadcrumbs.clear();
record.Published=std::make_shared<ClientlessFlightPath::SharedTrail>();
record.Published=record.Published->Replace(PointOf(record.Status.Landing));
record.Status.Detail=ownerLost?"replanning detached free flight":"replanning excessive obstacle avoidance";
ClientlessTravelTelemetry::Note(player,"flight-airborne-replan",record.Status.Detail);
TC_LOG_WARN("server.adventurebot.navigation","FLIGHT_AIRBORNE_REPLAN guid=%u ownerLost=%u segments=%u avoidances=%u target=(%.2f %.2f %.2f)",
@@ -775,9 +886,27 @@ void ClientlessFlightMgr::Update(uint32 diff)
}
if (record.Moving && player->IsSplineEnabled())
{
if (record.SegmentElapsed > 10000)
if (record.Progress.Stalled())
{
Fail(player, record, "flight segment made no confirmed progress");
TC_LOG_WARN("server.adventurebot.navigation",
"FLIGHT_STALLED guid=%u idleMs=%u segmentMs=%u expectedMs=%u overdue=%u recovery=%u",
guid, record.Progress.IdleMs, record.Progress.ElapsedMs, record.Progress.ExpectedMs,
record.Progress.Overdue()?1u:0u, record.StallRecoveries);
if (record.Returning || record.StallRecoveries >= 2)
{
Fail(player, record, "flight stalled for 60 seconds; local recovery exhausted");
continue;
}
++record.StallRecoveries;
player->StopMoving();
record.Moving = false;
// Keep the leader trail and destination. Re-evaluate the local
// join/corridor; never replace the macro route or return yet.
record.Join = {};
bool const departed = record.Memory.Departed;
record.Memory = {};
record.Memory.Departed = departed;
Advance(player, record);
continue;
}
auto ray =[&](Point const & a, Point const & b)
@@ -789,13 +918,13 @@ 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;
Advance(player, record);
}
else if (!record.Returning && player->movespline->timeRemaining() <= 600 &&
else if (!record.Returning && player->movespline->timeRemaining() <= 1500 &&
Distance(PointOf(record.Segment), PointOf(record.Status.Landing)) > 0.1f)
{
// Replace a still-running spline from its native current position.
@@ -3,6 +3,7 @@
#include "Common.h"
#include "Position.h"
#include "ClientlessFlightPath.h"
#include "ClientlessFlightProgress.h"
#include <string>
#include <memory>
#include <unordered_map>
@@ -44,16 +45,21 @@ class TC_GAME_API ClientlessFlightMgr
std::shared_ptr<ClientlessFlightPath::SharedTrail> Published=std::make_shared<ClientlessFlightPath::SharedTrail>();
std::shared_ptr<ClientlessFlightPath::SharedTrail> Following;
std::size_t SharedIndex=0;
ClientlessFlightPath::SharedJoin Join;
uint32 SharedWaitSince=0;
ClientlessFlightStatus Status;
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;
Position Departure;
uint32 TargetUpdated = 0, FormationDelay = 0;
uint32 DetachedRecoveryCount = 0;
ClientlessFlightProgress::Watch Progress;
uint32 StallRecoveries = 0;
bool ActionOwned = false;
std::string AbortReason;
std::vector < Position > Breadcrumbs;
@@ -61,6 +67,7 @@ class TC_GAME_API ClientlessFlightMgr
};
ClientlessFlightPath::Surface Sample(Player * player, Record & record, float x, float y) const;
bool Advance(Player * player, Record & record, bool handoff = false);
void Store(uint32 guid, Record&& record);
bool Begin(Player* player, Position const& target, std::string& error, bool following);
bool ApproachDeparture(Player* player, Record& record, bool following);
bool FindReturnLanding(Player* player, Record& record);
+143 -10
View File
@@ -5,6 +5,7 @@
#include <cmath>
#include <functional>
#include <limits>
#include <memory>
#include <vector>
// Geometry-only receding-horizon navigation. Server adapters supply phased
// terrain/ceiling and static+dynamic collision queries. No Player shortcuts.
@@ -18,6 +19,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<float>::infinity();
};
struct Settings
{
@@ -29,11 +33,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 +60,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 +74,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.
@@ -106,13 +118,68 @@ namespace ClientlessFlightPath
}
return true;
}
inline Result Next(Point const & from, Point const & goal, Settings const & cfg, Memory & memory, Sample const & sample, Ray const & ray)
inline Result Next(Point const & from, Point const & goal, Settings const & cfg, Memory & memory, Sample const & sample, Ray const & ray, bool airborneGoal = false)
{
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 (! here.Valid || ! end.Valid || (!airborneGoal && std::fabs(end.Ground - goal.Z) > 2.5f)) return
{
};
// Issuing a takeoff spline is not proof of actual clearance. Handoffs
// may replan while that spline is still close to its support surface.
if (!memory.Departed && from.Z >= here.Ground + cfg.Safety)
memory.Departed = true;
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<float>::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(score<best){best=score;column=low;}
}
if(!std::isfinite(best))return {};
memory.Column=column;memory.Hover=hover;memory.ElevatedGoal=goal;
memory.Elevated=Distance(from,column)>0.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 +217,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;
@@ -194,6 +264,7 @@ namespace ClientlessFlightPath
dz = std::min(0.0f, std::max(dz, here.Ground + cfg.Safety + 4 - from.Z));
}
}
if(airborneGoal)dz=std::clamp(dz,-run*cfg.Slope,run*cfg.Slope);
Point far
{
from.X + dx * run, from.Y + dy * run, from.Z + dz
@@ -215,7 +286,6 @@ namespace ClientlessFlightPath
// must not postpone descent until directly above the destination.
if (openApproach)
{
memory.Departed = true;
return best;
}
}
@@ -229,27 +299,90 @@ namespace ClientlessFlightPath
Point vertical{from.X,from.Y,top};
if (top > from.Z + 2.0f && Corridor(from,vertical,goal,cfg,sample,ray,true))
{
memory.Departed=true;
return {vertical,true,false,false};
}
}
if (best.Valid) memory.Departed = true;
return best;
}
struct SharedTrail
{
std::vector<Point> Points;
bool Aborted=false;
std::vector<bool> UnderOverhang;
Point Landing;
bool LandingReady=false;
bool Aborted=false, Complete=false;
std::shared_ptr<SharedTrail> Replacement;
std::shared_ptr<SharedTrail> Replace(Point const& landing)
{
auto next=std::make_shared<SharedTrail>();
next->Landing=landing;next->LandingReady=true;
Replacement=next;Aborted=true;
return next;
}
bool BindLanding(Point& goal) const
{
if(!LandingReady || Aborted)return false;
goal=Landing;return true;
}
};
struct SharedJoin
{
bool Active=true;
std::size_t Target=std::numeric_limits<std::size_t>::max();
Memory Navigation;
};
struct SharedResult {Result Step;bool Waiting=false;};
inline SharedResult FollowTrail(Point const& current,Point const& goal,Settings const& settings,
SharedTrail const& trail,std::size_t& index,Sample const& sample,Ray const& ray)
SharedTrail const& trail,std::size_t& index,Sample const& sample,Ray const& ray,float lookaheadDistance=0,SharedJoin* join=nullptr)
{
if(trail.Aborted)return {};
if(!trail.LandingReady)return {{},true};
if(join && join->Active)
{
if(trail.Points.empty())return {{},true};
if(join->Target==std::numeric_limits<std::size_t>::max())
{
// Join a nearby remaining leg instead of replaying the leader's
// entire history after a taxi, recovery or delayed departure.
std::vector<std::size_t> candidates;
for(std::size_t i=index;i<trail.Points.size();++i)candidates.push_back(i);
std::sort(candidates.begin(),candidates.end(),[&](auto a,auto b){
float da=Distance(current,trail.Points[a]),db=Distance(current,trail.Points[b]);
return da==db?a<b:da<db;
});
for(std::size_t n=0;n<std::min<std::size_t>(8,candidates.size());++n)
{
auto candidate=candidates[n];Memory memory;
auto step=Next(current,trail.Points[candidate],settings,memory,sample,ray,true);
if(!step.Valid)continue;
join->Target=candidate;join->Navigation=memory;
if(Distance(current,trail.Points[candidate])>=0.7f)
{step.Landing=false;return {step,false};}
break;
}
if(join->Target==std::numeric_limits<std::size_t>::max())return {};
}
if(Distance(current,trail.Points[join->Target])>=0.7f)
{
auto step=Next(current,trail.Points[join->Target],settings,join->Navigation,sample,ray,true);
step.Landing=false;return {step,false};
}
index=join->Target;join->Active=false;
}
while(index<trail.Points.size() && Distance(current,trail.Points[index])<0.7f)++index;
if(index==trail.Points.size())return {{},true};
// Extend a running spline before its endpoint. A corner may only be
// skipped if the NEW complete flight volume is verified; otherwise let
// the original checked corner finish. Never skip across an escape leg.
if(index+1<trail.Points.size() && Distance(current,trail.Points[index])<=lookaheadDistance)
{
bool escape=(index<trail.UnderOverhang.size() && trail.UnderOverhang[index]) ||
(index+1<trail.UnderOverhang.size() && trail.UnderOverhang[index+1]);
if(!escape && Corridor(current,trail.Points[index+1],goal,settings,sample,ray,true))++index;
}
auto next=trail.Points[index];
return {{next,Corridor(current,next,goal,settings,sample,ray,true),false,Distance(next,goal)<0.7f},false};
bool escape=index<trail.UnderOverhang.size() && trail.UnderOverhang[index];
return {{next,Corridor(current,next,goal,settings,sample,ray,true,escape),false,Distance(next,goal)<0.7f,escape},false};
}
}
@@ -0,0 +1,36 @@
#ifndef VCORE_CLIENTLESS_FLIGHT_PROGRESS_H
#define VCORE_CLIENTLESS_FLIGHT_PROGRESS_H
#include <algorithm>
#include <cmath>
#include <cstdint>
// Progress is measured against a fixed segment endpoint, not issued splines.
namespace ClientlessFlightProgress
{
struct Watch
{
unsigned IdleMs = 0, ElapsedMs = 0, ExpectedMs = 0;
float BestDistance = 0;
void Begin(float distance, unsigned expectedMs)
{
BestDistance = distance;
ExpectedMs = expectedMs;
IdleMs = ElapsedMs = 0;
}
void Observe(float distance, unsigned diff)
{
ElapsedMs += std::min(diff, 30000u);
// Accumulate sub-tick motion; jitter or moving away is not progress.
if (std::isfinite(distance) && BestDistance - distance >= 0.5f)
{
BestDistance = distance;
IdleMs = 0;
}
else
IdleMs = std::min(60000u, IdleMs + std::min(diff, 30000u));
}
bool Stalled() const { return IdleMs >= 60000; }
// Duration overruns are diagnostic only: a progressing flight is healthy.
bool Overdue() const { return ElapsedMs > ExpectedMs + 5000u; }
};
}
#endif
@@ -231,6 +231,8 @@ int Skill(lua_State* L)
if(!sSkillLineStore.LookupEntry(skill) || value>maximum)return luaL_error(L,"invalid skill or skill value exceeds maximum");
p->SetSkill(uint16(skill),p->GetSkillStep(uint16(skill)),uint16(value),uint16(maximum));lua_pushboolean(L,p->GetPureSkillValue(skill)==value);return 1;
}
int HasAura(lua_State* L){auto p=Actor(L);lua_pushboolean(L,p->HasAura(Number(L,2)));return 1;}
int RemoveAura(lua_State* L){auto p=Actor(L);p->RemoveAurasDueToSpell(Number(L,2));return 0;}
int Count(lua_State* L){auto p=Actor(L);lua_pushinteger(L,p->GetItemCount(Number(L,2),false));return 1;}
int Quest(lua_State* L){auto p=Actor(L);auto id=Number(L,2);lua_pushinteger(L,p->GetQuestStatus(id));lua_pushboolean(L,p->GetQuestRewardStatus(id));return 2;}
int Find(lua_State* L)
@@ -425,7 +427,7 @@ bool Start(std::string const& id,fs::path path,std::string& error)
for(auto name:{"io","os","package","debug","dofile","loadfile","require","load","collectgarbage","coroutine","pcall","xpcall"}){lua_pushnil(L);lua_setglobal(L,name);}
lua_newtable(L);
for(auto const& fn:std::initializer_list<std::pair<char const*,lua_CFunction>>{
{"npc_hello",NpcHello},{"menu",Menu},{"spellclick",SpellClick},{"exit_vehicle",ExitVehicle},{"request_cast",RequestCast},{"request_cast_at",RequestCastAt},{"use_item_at",UseItemAt},{"area_trigger",AreaTrigger},{"inventory",Inventory},{"stop",Stop},{"now_ms",Now},{"spawn",Spawn},{"online",Online},{"_create",Create},{"_create_result",CreateResult},{"wait",Wait},{"state",Snapshot},{"teleport",Teleport},{"level",Level},{"stats",Stats},{"stat",Stat},{"learn",Learn},{"item",Item},{"equip",Equip},{"clear_slot",ClearSlot},{"remove_item",RemoveItem},{"skill",Skill},{"item_count",Count},{"quest",Quest},{"use",Use},{"hello",Hello},{"accept",Accept},{"abandon",Abandon},{"reward",Reward},{"gossip",Gossip},{"cast",Cast},{"move",Move},{"face",Face},{"attack",Attack},{"target_state",TargetState},{"objectives",Objectives},{"request_reward",RequestReward},{"loot",Loot},{"loot_item",LootItem},{"loot_release",LootRelease},{"use_item",UseItem},{"record",Record},{"check",Check}})Bind(L,r.get(),fn.first,fn.second);
{"npc_hello",NpcHello},{"menu",Menu},{"spellclick",SpellClick},{"exit_vehicle",ExitVehicle},{"request_cast",RequestCast},{"request_cast_at",RequestCastAt},{"use_item_at",UseItemAt},{"area_trigger",AreaTrigger},{"inventory",Inventory},{"stop",Stop},{"now_ms",Now},{"spawn",Spawn},{"online",Online},{"_create",Create},{"_create_result",CreateResult},{"wait",Wait},{"state",Snapshot},{"teleport",Teleport},{"level",Level},{"stats",Stats},{"stat",Stat},{"learn",Learn},{"item",Item},{"equip",Equip},{"clear_slot",ClearSlot},{"remove_item",RemoveItem},{"skill",Skill},{"has_aura",HasAura},{"remove_aura",RemoveAura},{"item_count",Count},{"quest",Quest},{"use",Use},{"hello",Hello},{"accept",Accept},{"abandon",Abandon},{"reward",Reward},{"gossip",Gossip},{"cast",Cast},{"move",Move},{"face",Face},{"attack",Attack},{"target_state",TargetState},{"objectives",Objectives},{"request_reward",RequestReward},{"loot",Loot},{"loot_item",LootItem},{"loot_release",LootRelease},{"use_item",UseItem},{"record",Record},{"check",Check}})Bind(L,r.get(),fn.first,fn.second);
Bind(L,r.get(),"nearest_creature",Nearest);Bind(L,r.get(),"nearest_object",Nearest,1);
Bind(L,r.get(),"creature",Find);Bind(L,r.get(),"object",Find,1);lua_setglobal(L,"testbot");
char const* helpers=R"lua(
@@ -85,7 +85,8 @@ bool ReturnToRecordedStand(Player* player,GameObject* object,Position const& anc
auto transport=object->ToTransportBase();
if(!transport || player->GetTransport()!=transport || !player->IsAlive() || player->IsInCombat() ||
player->IsBeingTeleported() || player->GetMap()!=object->GetMap() ||
!object->GetTransportDockState(dock) || !dock.Stopped || uint64(dock.RemainingPause)<uint64(exitBudget)+1000)return false;
!object->GetTransportDockState(dock) || !dock.Stopped ||
(object->GetGoType()!=GAMEOBJECT_TYPE_TRANSPORT && uint64(dock.RemainingPause)<uint64(exitBudget)+1000))return false;
float x=anchor.GetPositionX(),y=anchor.GetPositionY(),z=anchor.GetPositionZ(),o=anchor.GetOrientation();
if(!std::isfinite(x) || !std::isfinite(y) || !std::isfinite(z) ||
std::fabs(x)>100 || std::fabs(y)>100 || std::fabs(z)>100)return false;
@@ -120,7 +121,8 @@ bool ClientlessTransportDeckMgr::Update(Player* player,GameObject* object,Positi
Point anchor{exitAnchor.GetPositionX(),exitAnchor.GetPositionY(),exitAnchor.GetPositionZ()};
TransportDockState dock;
if(!object->GetTransportDockState(dock) || !dock.Stopped)return false;
if(Distance(Local(player),anchor)<=0.65f)return dock.RemainingPause>=exitBudget;
if(Distance(Local(player),anchor)<=0.65f)
return object->GetGoType()==GAMEOBJECT_TYPE_TRANSPORT || dock.RemainingPause>=exitBudget;
return ReturnToRecordedStand(player,object,exitAnchor,exitBudget);
}
uint32 guid=player->GetGUID().GetCounter();auto& state=Passengers[guid];
@@ -22,8 +22,10 @@
#include "Player.h"
#include "Transport.h"
#include "TransportMgr.h"
#include "Spline.h"
#include "World.h"
#include "WorldSession.h"
#include <G3D/Quat.h>
#include <algorithm>
#include <cmath>
#include <limits>
@@ -326,6 +328,63 @@ ClientlessTransportStop const* ClientlessTransportMgr::GetStop(std::string const
if(!EnsureLoaded())return nullptr;
auto found=_stops.find(node);return found==_stops.end()?nullptr:&found->second;
}
float ClientlessTransportMgr::ConnectionDistance(std::string const& from,std::string const& to)
{
auto source=GetStop(from),target=GetStop(to);
if(!source || !target || source->Entry!=target->Entry)return 0;
struct Sample { uint32 Map;Position Pos;double Arc; };
std::vector<Sample> samples;
double totalLength=0;
if(auto native=sTransportMgr->GetTransportTemplate(source->Entry))
{
// Native splines already expose cumulative arclength at each stop.
// Sum their legs, never geographic jumps between maps/teleports.
for(auto const& leg:native->PathLegs)
{
if(!leg.Spline || leg.Spline->length()<=0)continue;
G3D::Vector3 point;
leg.Spline->evaluate_percent(0,point);
samples.push_back({leg.MapId,Position(point.x,point.y,point.z),totalLength});
for(auto const& segment:leg.Segments)
{
leg.Spline->evaluate_percent(float(segment.DistanceFromLegStartAtEnd/leg.Spline->length()),point);
samples.push_back({leg.MapId,Position(point.x,point.y,point.z),totalLength+segment.DistanceFromLegStartAtEnd});
}
totalLength+=leg.Spline->length();
}
}
else if(auto animation=sTransportMgr->GetTransportAnimInfo(source->Entry))
{
auto spawn=sObjectMgr->GetGameObjectData(source->Spawn);
if(!spawn)return 0;
QuaternionData parent;
if(auto addon=sObjectMgr->GetGameObjectAddon(source->Spawn))parent=addon->ParentRotation;
auto rotation=G3D::Quat(parent.x,parent.y,parent.z,parent.w).toRotationMatrix();
for(auto const& node:animation->Path)
{
auto const& p=node.second->Pos;
G3D::Vector3 world=G3D::Vector3(p.X,p.Y,p.Z)*rotation;
Position position(spawn->spawnPoint.GetPositionX()+world.x,spawn->spawnPoint.GetPositionY()+world.y,spawn->spawnPoint.GetPositionZ()+world.z);
if(!samples.empty())totalLength+=samples.back().Pos.GetExactDist(position);
samples.push_back({source->Map,position,totalLength});
}
if(samples.size()>1)totalLength+=samples.back().Pos.GetExactDist(samples.front().Pos);
}
if(samples.size()<2 || totalLength<=0)return 0;
auto nearest=[&](ClientlessTransportStop const& stop)
{
size_t result=samples.size();float best=std::numeric_limits<float>::infinity();
for(size_t i=0;i<samples.size();++i)if(samples[i].Map==stop.Map)
{float distance=samples[i].Pos.GetExactDist(stop.Dock);if(distance<best){best=distance;result=i;}}
return result;
};
auto begin=nearest(*source),end=nearest(*target);
if(begin>=samples.size() || end>=samples.size() || begin==end)return 0;
double length=samples[end].Arc-samples[begin].Arc;
if(length<0)length+=totalLength;
return float(length);
}
bool ClientlessTransportMgr::ObserveJourney(Player* player,uint32 lastMap,Position const& ground,std::string& from,std::string& to)
{
from.clear();to.clear();if(!player->GetTransport() || !EnsureLoaded())return false;
@@ -405,6 +464,8 @@ bool ClientlessTransportMgr::Start(Player* player, std::string const& from, std:
Ride ride;
ride.From = source; ride.To = _stops.at(to);
ride.RequiredPause = uint32(PathLength(source) / std::max(1.0f, player->GetSpeed(MOVE_RUN)) * 1000.0f) + uint32(source.Points.size()) * 100 + 1500;
if(auto object=FindObject(player,source);object && object->GetGoType()==GAMEOBJECT_TYPE_TRANSPORT)
ride.RequiredPause=500;
ride.Status.Phase = ClientlessTransportPhase::Waiting;
ride.Status.Detail = "waiting for recorded transport at departure dock";
player->RemoveAurasByType(SPELL_AURA_MOUNTED);
@@ -435,10 +496,21 @@ bool ClientlessTransportMgr::ResumePassenger(Player* player)
auto node=sClientlessTravelGraphMgr->FindNode(stop.Node);
if(!object || object->ToTransportBase()!=player->GetTransport() || !node ||
!sAdventureAreaMgr->AllowsNode(player,*node))continue;
if(!source || stop.Node<source->Node)source=&stop;
if(!source || (object->GetGoType()==GAMEOBJECT_TYPE_TRANSPORT ?
Distance(*player,stop.Dock)<Distance(*player,source->Dock) : stop.Node<source->Node))source=&stop;
}
if(!source)return false; // unknown vehicles and forbidden stops retain their owner
Ride ride;ride.From=ride.To=*source;ride.Resumed=true;
// A restored passenger has no live boarding event. Use the nearest recorded
// floor as its recovery origin, only when there is one unambiguous opposite.
auto object=FindObject(player,*source);
if(object && object->GetGoType()==GAMEOBJECT_TYPE_TRANSPORT)
for(auto const& entry:_stops)
if(entry.first!=source->Node && SameService(*source,entry.second))
{
if(!ride.ElevatorDestination.empty()){ride.ElevatorDestination.clear();break;}
ride.ElevatorDestination=entry.first;
}
ride.Status.Phase=ClientlessTransportPhase::Riding;
ride.Status.Detail="restored passenger; waiting for a permitted recorded dock";
ClientlessTransportDeckMgr::Clear(guid);
@@ -490,6 +562,34 @@ void ClientlessTransportMgr::Cancel(uint32 guid)
if (itr != _rides.end()) itr->second.Abort = true;
}
bool ClientlessTransportMgr::RecoverStuckElevator(Player* player, GameObject* object, Ride& ride, uint32 diff)
{
if(!object || object->GetGoType()!=GAMEOBJECT_TYPE_TRANSPORT ||
player->GetTransport()!=object->ToTransportBase())return false;
if(ride.ElevatorDestination.empty() && ride.From.Node!=ride.To.Node)
ride.ElevatorDestination=ride.To.Node;
ride.ElevatorPassengerMs=uint32(std::min<uint64>(300000,uint64(ride.ElevatorPassengerMs)+diff));
if(ride.ElevatorPassengerMs<300000 || !player->IsAlive())return false;
auto destination=_stops.find(ride.ElevatorDestination);
if(destination==_stops.end() || destination->second.Points.empty() ||
!SameService(ride.From,destination->second))return false;
auto node=sClientlessTravelGraphMgr->FindNode(destination->first);
if(!node || (sAdventureAreaMgr->Applies(player) && !sAdventureAreaMgr->AllowsNode(player,*node)))return false;
auto const& target=destination->second.Points.front().Pos;
player->StopMoving();
// The native teleport removes the real passenger attachment and completes
// through the normal clientless teleport ACK, including same-map transfers.
if(!player->TeleportTo(destination->second.Map,target.GetPositionX(),target.GetPositionY(),
target.GetPositionZ(),target.GetOrientation()))return false;
ride.To=destination->second;
ride.Status.Recovering=false;
ride.ElevatorFallback=true;
SetPhase(player,ride,ClientlessTransportPhase::Riding,"five-minute elevator fallback to destination node");
TC_LOG_WARN("server.adventurebot.navigation","ELEVATOR_STUCK_TELEPORT guid=%u from='%s' to='%s' passengerMs=%u",
player->GetGUID().GetCounter(),ride.From.Node.c_str(),ride.To.Node.c_str(),ride.ElevatorPassengerMs);
return true;
}
void ClientlessTransportMgr::UpdateRide(Player* player, Ride& ride, uint32 diff)
{
using Phase = ClientlessTransportPhase;
@@ -500,10 +600,19 @@ void ClientlessTransportMgr::UpdateRide(Player* player, Ride& ride, uint32 diff)
player->GetSession()->CompleteClientlessTeleport();
return;
}
if(ride.ElevatorFallback)
{
if(!player->GetTransport() && player->GetMapId()==ride.To.Map &&
Distance(*player,ride.To.Points.front().Pos)<2.5f)
SetPhase(player,ride,ride.Abort?Phase::Failed:Phase::Arrived,"elevator fallback reached destination node");
return;
}
GameObject* object = FindObject(player, ride.From);
if(RecoverStuckElevator(player,object,ride,diff))return;
if(ride.Status.Recovering){RecoverPassenger(player,ride,diff);return;}
if(ride.RetryDelay){ride.RetryDelay=diff>=ride.RetryDelay?0:ride.RetryDelay-diff;return;}
GameObject* object = FindObject(player, ride.From);
uint32 limit = uint32(std::min<uint64>(1800000, std::max<uint64>(180000, uint64(ride.From.Period) * 3 + 30000)));
if(object && object->GetGoType()==GAMEOBJECT_TYPE_TRANSPORT)limit=std::max(limit,330000u);
auto fail = [&](char const* detail)
{
player->StopMoving();
@@ -548,7 +657,8 @@ void ClientlessTransportMgr::UpdateRide(Player* player, Ride& ride, uint32 diff)
return;
}
if (ride.Abort) { fail("transport cancelled on land"); return; }
if (!AtDock(player, object, ride.From, ride.RequiredPause))
uint32 boardingPause=object && object->GetGoType()==GAMEOBJECT_TYPE_TRANSPORT ? 500u : ride.RequiredPause;
if (!AtDock(player, object, ride.From, boardingPause))
{
if(!ride.WaitTried && !player->GetTransport() && !player->IsInCombat())
{
@@ -612,7 +722,8 @@ void ClientlessTransportMgr::UpdateRide(Player* player, Ride& ride, uint32 diff)
if (permittedArrival && AtDock(player, object, ride.To, 0)) arrival = &ride.To;
else if (!ride.Resumed && ride.Abort && AtDock(player, object, ride.From, 0)) arrival = &ride.From;
auto const& exit = arrival ? *arrival : ride.From;
uint32 exitBudget=uint32(PathLength(exit)/std::max(1.0f,player->GetSpeed(MOVE_RUN))*1000)+uint32(exit.Points.size())*std::max(100u,std::min(diff,2000u)*2)+2500;
uint32 exitBudget=object->GetGoType()==GAMEOBJECT_TYPE_TRANSPORT ? 0u :
uint32(PathLength(exit)/std::max(1.0f,player->GetSpeed(MOVE_RUN))*1000)+uint32(exit.Points.size())*std::max(100u,std::min(diff,2000u)*2)+2500;
bool ready=ClientlessTransportDeckMgr::Update(player,object,exit.Points.back().Pos,arrival!=nullptr,exitBudget);
if (!arrival || !ready || !AtDock(player,object,*arrival,exitBudget)) return;
if (arrival == &ride.From) ride.To = ride.From;
@@ -622,11 +733,16 @@ void ClientlessTransportMgr::UpdateRide(Player* player, Ride& ride, uint32 diff)
if (ride.Status.Phase != Phase::Boarding && ride.Status.Phase != Phase::Leaving) return;
bool boarding = ride.Status.Phase == Phase::Boarding;
auto const& stop = boarding ? ride.From : ride.To;
if (!object || !AtDock(player, object, stop, 0))
bool landedElevatorExit = !boarding && object && object->GetGoType()==GAMEOBJECT_TYPE_TRANSPORT &&
!player->GetTransport() && ride.Point<stop.Points.size() && !stop.Points[ride.Point].OnTransport;
if (!landedElevatorExit && (!object || !AtDock(player, object, stop, 0)))
{
if (player->GetTransport())
{
player->StopMoving(); ride.Abort = true;
player->StopMoving();
// A short elevator stop can end after a legitimate boarding/exit
// began. Retain the passenger and retry, without cancelling its trip.
if(!object || object->GetGoType()!=GAMEOBJECT_TYPE_TRANSPORT)ride.Abort = true;
SetPhase(player, ride, Phase::Riding, "departure during transition; retaining passenger until safe dock");
}
else fail("transport departed during land transition");
@@ -50,6 +50,7 @@ public:
static ClientlessTransportMgr* instance();
bool Reload();
bool HasConnection(std::string const& from, std::string const& to);
float ConnectionDistance(std::string const& from, std::string const& to);
bool ObserveJourney(Player* passenger, uint32 lastMap, Position const& lastGround, std::string& from, std::string& to);
bool ArrivalNode(Player* player, std::string const& from, std::string& to);
ClientlessTransportStop const* GetStop(std::string const& node);
@@ -82,6 +83,9 @@ private:
uint32 RequiredPause = 0;
uint32 PointTimeout = 0;
uint32 RetryDelay = 0;
uint32 ElevatorPassengerMs = 0;
std::string ElevatorDestination;
bool ElevatorFallback = false;
bool Moving = false;
bool Abort = false;
bool Resumed = false;
@@ -95,6 +99,7 @@ private:
bool Sample(Player* player, Recording& recording, bool force);
bool Save(Player* player, std::string const& stand, std::string& error);
void UpdateRide(Player* player, Ride& ride, uint32 diff);
bool RecoverStuckElevator(Player* player, GameObject* object, Ride& ride, uint32 diff);
void RecoverPassenger(Player* player, Ride& ride, uint32 diff);
bool SelectResumedDock(Player* player, GameObject* object, Ride& ride);
void SetPhase(Player* player, Ride& ride, ClientlessTransportPhase phase, char const* detail);
@@ -0,0 +1,57 @@
#ifndef VCORE_CLIENTLESS_TRAVEL_COST_H
#define VCORE_CLIENTLESS_TRAVEL_COST_H
#include <algorithm>
#include <array>
#include <cmath>
#include <cstdint>
#include <string>
namespace ClientlessTravelCost
{
enum Mode : unsigned { Ground, Taxi, Flight, Ship, Zeppelin, Tram, Portal, Count };
inline Mode Type(std::string const& type)
{
if(type=="taxi")return Taxi;
if(type=="free_flight")return Flight;
if(type=="ship")return Ship;
if(type=="zeppelin")return Zeppelin;
if(type=="tram" || type=="elevator")return Tram;
if(type=="portal" || type=="portal_use")return Portal;
return Ground;
}
inline char const* Name(Mode mode)
{
static char const* const names[]={"Ground","Taxi","Flight","Ship","Zeppelin","Tram","Portal"};
return names[mode];
}
struct Profile
{
std::array<float,Count> Factors{{1,1,1,1,1,1,1}};
std::uint32_t Seed=0;
float Cost(Mode mode,float distance) const
{
// A teleport traverses no world-space road. Its transition remains
// positive; coordinates on different maps are never subtracted.
float length=mode==Portal?1.0f:std::max(0.0f,distance);
return length*Factors[mode];
}
float Cost(std::string const& type,float distance)const{return Cost(Type(type),distance);}
};
inline Profile Sample(Profile base,std::uint32_t seed,float variation)
{
variation=std::isfinite(variation)?std::clamp(variation,0.0f,0.9f):0.0f;
base.Seed=seed;
// Separate deterministic draws per mode, never per edge or search probe.
for(unsigned mode=0;mode<Count;++mode)
{
std::uint32_t x=seed+0x9e3779b9u*(mode+1);
x^=x>>16;x*=0x7feb352du;x^=x>>15;x*=0x846ca68bu;x^=x>>16;
float u=float(x>>8)/16777215.0f;
float factor=std::isfinite(base.Factors[mode])?std::clamp(base.Factors[mode],0.01f,100.0f):1.0f;
base.Factors[mode]=factor*(1.0f+variation*(2.0f*u-1.0f));
}
return base;
}
}
#endif
+364 -112
View File
@@ -3,8 +3,12 @@
#include "ClientlessTravelGraphMgr.h"
#include "ClientlessTransportMgr.h"
#include "ClientlessFlightMgr.h"
#include "ClientlessLuaEngine.h"
#include "DatabaseEnv.h"
#include "DBCStores.h"
#include "Config.h"
#include "Random.h"
#include "Log.h"
#include "Player.h"
#include "ClientlessTravelMgr.h"
@@ -125,7 +129,7 @@ ClientlessPortalUse const* ClientlessTravelGraphMgr::GetPortalUse(uint32 edgeDat
return itr != _portalUses.end() ? &itr->second : nullptr;
}
bool ClientlessTravelGraphMgr::PortalPrerequisites(Player const* player,uint32 edgeDatabaseId) const
bool ClientlessTravelGraphMgr::PortalNativePrerequisites(Player const* player,uint32 edgeDatabaseId) const
{
auto portal=GetPortalUse(edgeDatabaseId);
auto info=portal?sObjectMgr->GetGameObjectTemplate(portal->Entry):nullptr;
@@ -136,6 +140,23 @@ bool ClientlessTravelGraphMgr::PortalPrerequisites(Player const* player,uint32 e
return !portal->Requirements.empty();
}
bool ClientlessTravelGraphMgr::AdventurePortalUnlockAllowed(Player const* player,
uint32 edgeDatabaseId) const
{
// AdventureBots cannot acquire quest progress. Once they have reached the
// Cataclysm level range, graph-bound public travel portals are therefore
// considered unlocked. This does not bypass faction/area/node policy or
// make arbitrary, unbound GameObjects usable.
return player && player->getLevel() >= 80 && GetPortalUse(edgeDatabaseId) &&
sClientlessLuaEngine->GetAdventureProfile(player->GetGUID().GetCounter());
}
bool ClientlessTravelGraphMgr::PortalPrerequisites(Player const* player,uint32 edgeDatabaseId) const
{
return PortalNativePrerequisites(player,edgeDatabaseId) ||
AdventurePortalUnlockAllowed(player,edgeDatabaseId);
}
bool ClientlessTravelGraphMgr::Reload()
{
_positionProbes.Clear();
@@ -299,6 +320,7 @@ bool ClientlessTravelGraphMgr::Reload()
while (pointResult->NextRow());
}
RebuildEdgeDistances();
_loaded = true;
TC_LOG_INFO("server.adventurebot",
@@ -308,6 +330,61 @@ bool ClientlessTravelGraphMgr::Reload()
return !_nodes.empty();
}
ClientlessTravelCost::Profile ClientlessTravelGraphMgr::NewJourneyCosts() const
{
ClientlessTravelCost::Profile base;
for(unsigned mode=0;mode<ClientlessTravelCost::Count;++mode)
base.Factors[mode]=sConfigMgr->GetFloatDefault(
std::string("Clientless.Travel.CostMultiplier.")+ClientlessTravelCost::Name(ClientlessTravelCost::Mode(mode)),1.0f);
return ClientlessTravelCost::Sample(base,urand(1,0xffffffffu),
sConfigMgr->GetFloatDefault("Clientless.Travel.CostVariation",0.20f));
}
void ClientlessTravelGraphMgr::RebuildEdgeDistances()
{
for(auto& outgoing:_edges)for(auto& edge:outgoing.second)
{
auto const& from=_nodes.at(edge.FromNode);auto const& to=_nodes.at(edge.ToNode);
float length=0;
auto segment=[&](uint32 map,float x,float y,float z,uint32 otherMap,float a,float b,float c)
{if(map==otherMap)length+=std::hypot(std::hypot(x-a,y-b),z-c);};
if(edge.Type=="portal" || edge.Type=="portal_use")length=1;
else if(edge.Type=="taxi")
{
if(edge.FromNode.rfind("taxi_",0)==0 && edge.ToNode.rfind("taxi_",0)==0)
{
uint32 path=0,fare=0;
sObjectMgr->GetTaxiPath(uint32(std::strtoul(edge.FromNode.c_str()+5,nullptr,10)),
uint32(std::strtoul(edge.ToNode.c_str()+5,nullptr,10)),path,fare);
if(path<sTaxiPathNodesByPath.size())
{
auto const& nodes=sTaxiPathNodesByPath[path];
for(size_t i=1;i<nodes.size();++i)
segment(nodes[i-1]->ContinentID,nodes[i-1]->Loc.X,nodes[i-1]->Loc.Y,nodes[i-1]->Loc.Z,
nodes[i]->ContinentID,nodes[i]->Loc.X,nodes[i]->Loc.Y,nodes[i]->Loc.Z);
}
}
}
else if(ClientlessTransportMgr::IsTransportEdge(edge.Type))
length=sClientlessTransportMgr->ConnectionDistance(edge.FromNode,edge.ToNode);
else if(auto points=GetEdgePoints(edge.DatabaseId);points && !points->empty())
{
auto const& first=edge.Reverse?points->back():points->front();
auto const& last=edge.Reverse?points->front():points->back();
segment(from.MapId,from.X,from.Y,from.Z,first.MapId,first.X,first.Y,first.Z);
for(size_t i=1;i<points->size();++i)
segment((*points)[i-1].MapId,(*points)[i-1].X,(*points)[i-1].Y,(*points)[i-1].Z,
(*points)[i].MapId,(*points)[i].X,(*points)[i].Y,(*points)[i].Z);
segment(last.MapId,last.X,last.Y,last.Z,to.MapId,to.X,to.Y,to.Z);
}
if(length<=0 && from.MapId==to.MapId)
segment(from.MapId,from.X,from.Y,from.Z,to.MapId,to.X,to.Y,to.Z);
edge.Cost=std::isfinite(length)&&length>0?length:std::numeric_limits<float>::infinity();
TC_LOG_DEBUG("server.adventurebot.navigation","TRAVEL_EDGE_DISTANCE edge=%u type='%s' reverse=%u distance=%.2f",
edge.DatabaseId,edge.Type.c_str(),edge.Reverse?1u:0u,edge.Cost);
}
}
std::string ClientlessTravelGraphMgr::FindNearestStartNode(
Player const* player,
float maxDistance) const
@@ -346,8 +423,11 @@ bool ClientlessTravelGraphMgr::PlanRoute(
float startRadius,
std::vector<ClientlessTravelRouteStep>& route,
std::string& error,
std::unordered_set<uint32> const* blockedEdgeIds)
std::unordered_set<uint32> const* blockedEdgeIds,
ClientlessTravelCost::Profile const* journeyCosts)
{
ClientlessTravelCost::Profile const defaults;
auto const& costs=journeyCosts?*journeyCosts:defaults;
route.clear();
error.clear();
@@ -430,7 +510,7 @@ bool ClientlessTravelGraphMgr::PlanRoute(
blockedEdgeIds->find(edge.DatabaseId) != blockedEdgeIds->end())
continue;
float const candidate = current.Cost + edge.Cost;
float const candidate = current.Cost + costs.Cost(edge.Type,edge.Cost);
if (candidate >= distance[edge.ToNode])
continue;
@@ -557,7 +637,8 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
std::string& error,
std::unordered_set<uint32> const* blockedEdgeIds,
std::unordered_set<std::string> const* blockedBridgeKeys,
ClientlessTravelLocationGoal const* locationGoal)
ClientlessTravelLocationGoal const* locationGoal,
ClientlessTravelCost::Profile const* journeyCosts)
{
struct Trace
{
@@ -621,8 +702,17 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
if (!sAdventureAreaMgr->AllowsPosition(player,locationGoal->MapId,goal))
{ error="destination location denied by area catalog"; return false; }
}
ClientlessTravelCost::Profile const defaults;
auto const& costs=journeyCosts?*journeyCosts:defaults;
auto groundCost=[&](float length){return costs.Cost(ClientlessTravelCost::Ground,length);};
auto flightDistanceCost=[&](float length){return costs.Cost(ClientlessTravelCost::Flight,length);};
auto edgeCost=[&](ClientlessTravelEdge const& edge){return costs.Cost(edge.Type,edge.Cost);};
bool const flightEnabled = sClientlessFlightMgr->Enabled(player);
constexpr std::uint64_t MaxFlightEstimatesPerPlan = 16;
// Preserve part of the global budget for nodes reached later in the
// search. In particular, a flight to a portal source must still leave
// enough probes for the flight from its destination map to the goal.
constexpr std::uint64_t MaxFlightEstimatesPerOrigin = 6;
auto flightCost = [&](ClientlessTravelNode const& from, uint32 map, float x, float y, float z, bool access = false)
{
if (!flightEnabled || from.MapId != map ||
@@ -644,12 +734,10 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
return std::numeric_limits<float>::infinity();
auto begin=std::chrono::steady_clock::now();++trace.FlightCalls;
float result=sClientlessFlightMgr->Estimate(player,map,a,b,access);
if(access && std::isfinite(result))
if(std::isfinite(result))
{
float distance=a.GetExactDist(&b);
if(distance<20)result=std::numeric_limits<float>::infinity();
else result=(sClientlessFlightMgr->Preferred(player,map,a) || sClientlessFlightMgr->Preferred(player,map,b))?
40+distance*0.4f:180+distance*1.5f;
float length=a.GetExactDist(&b);
result=access && length<20?std::numeric_limits<float>::infinity():flightDistanceCost(length);
}
trace.FlightUs+=std::chrono::duration_cast<std::chrono::microseconds>(std::chrono::steady_clock::now()-begin).count();
flightEstimates.emplace(estimateKey, result);
@@ -665,45 +753,38 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
bridgeRadius = MaxVirtualNodeBridge;
bridgeRadius = std::min(bridgeRadius, MaxVirtualNodeBridge);
// The overwhelmingly common free-flight case is a same-map Location. Do
// not construct a macro graph first: Estimate already enforces licenses,
// mount flags, area policy, distance and the complete catalog segment.
// Native departure/landing validation still happens in FlightMgr::Start.
if (locationGoal && locationGoal->MapId == player->GetMapId())
float directFlightCost=std::numeric_limits<float>::infinity();
auto directFlightRoute=[&]()
{
ClientlessTravelNode departureNode;
departureNode.Key = "flight_departure";
departureNode.MapId = player->GetMapId();
departureNode.X = player->GetPositionX();
departureNode.Y = player->GetPositionY();
departureNode.Z = player->GetPositionZ();
float const directFlight = flightCost(departureNode, locationGoal->MapId,
locationGoal->X, locationGoal->Y, locationGoal->Z);
if (std::isfinite(directFlight))
{
ClientlessTravelRouteStep departure;
departure.NodeKey = "flight_departure";
departure.NodeType = "ground";
departure.EdgeType = "start";
departure.MapId = player->GetMapId();
departure.X = player->GetPositionX();
departure.Y = player->GetPositionY();
departure.Z = player->GetPositionZ();
route.push_back(std::move(departure));
ClientlessTravelRouteStep departure;
departure.NodeKey = "flight_departure";
departure.NodeType = "ground";
departure.EdgeType = "start";
departure.MapId = player->GetMapId();
departure.X = player->GetPositionX();
departure.Y = player->GetPositionY();
departure.Z = player->GetPositionZ();
route.push_back(std::move(departure));
ClientlessTravelRouteStep flight;
flight.NodeKey = "flight_location";
flight.NodeType = "location";
flight.EdgeType = "free_flight";
flight.MapId = locationGoal->MapId;
flight.X = locationGoal->X;
flight.Y = locationGoal->Y;
flight.Z = locationGoal->Z;
route.push_back(std::move(flight));
complete = true;
reachedNode = "flight_location";
return true;
}
ClientlessTravelRouteStep flight;
flight.NodeKey = "flight_location";
flight.NodeType = "location";
flight.EdgeType = "free_flight";
flight.MapId = locationGoal->MapId;
flight.X = locationGoal->X;
flight.Y = locationGoal->Y;
flight.Z = locationGoal->Z;
route.push_back(std::move(flight));
complete = true;
reachedNode = "flight_location";
return true;
};
if(locationGoal && locationGoal->MapId==player->GetMapId())
{
ClientlessTravelNode departure;
departure.Key="flight_departure";departure.MapId=player->GetMapId();
departure.X=player->GetPositionX();departure.Y=player->GetPositionY();departure.Z=player->GetPositionZ();
directFlightCost=flightCost(departure,locationGoal->MapId,locationGoal->X,locationGoal->Y,locationGoal->Z);
}
struct State
@@ -785,6 +866,10 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
std::unordered_set<std::string> flightRoots;
uint32 startCount = 0;
// Unreachable nearby pavement/indoor anchors must not consume the entire
// flight budget before graph search can consider flight-only connections.
std::uint64_t startFlightProbes = 0;
constexpr std::uint64_t MaxStartFlightProbes = 4;
for (auto const& pair : _nodes)
{
ClientlessTravelNode const& node = pair.second;
@@ -798,7 +883,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
float const localDistance = std::sqrt(dx * dx + dy * dy + dz * dz);
if (localDistance > startRadius)
continue;
float startCost = localDistance;
float startCost = groundCost(localDistance);
// VCORE_VIRTUAL_EDGE_V11_PORTAL_APPROACH: never seed an unreachable node.
if (blockedBridgeKeys && blockedBridgeKeys->count("start:" + node.Key))
@@ -815,9 +900,18 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
ClientlessTravelNode departure;
departure.Key="flight_departure";
departure.MapId=player->GetMapId();departure.X=player->GetPositionX();departure.Y=player->GetPositionY();departure.Z=player->GetPositionZ();
float air=blockedBridgeKeys && blockedBridgeKeys->count("flight:flight_departure->"+node.Key)?
std::numeric_limits<float>::infinity():flightCost(departure,node.MapId,node.X,node.Y,node.Z,!reachable);
if(std::isfinite(air) && (!reachable || air<entryNav.PathLength))
// A valid local MMAP entry is the normal, organic graph access and
// must not spend the bounded flight-estimate budget. Free flight is
// the recovery/re-entry mode for nodes that local navigation cannot
// reach, not a shortcut racing every nearby pavement node.
float air = std::numeric_limits<float>::infinity();
if (!reachable && startFlightProbes < MaxStartFlightProbes && !(blockedBridgeKeys &&
blockedBridgeKeys->count("flight:flight_departure->"+node.Key)))
{
++startFlightProbes;
air=flightCost(departure,node.MapId,node.X,node.Y,node.Z,true);
}
if(std::isfinite(air) && !reachable)
{flightRoots.insert(node.Key);startCost=air;}
else
{
@@ -842,7 +936,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
node.Key.c_str(), localDistance, entryNav.Reason.c_str());
continue;
}
startCost = entryNav.PathLength;
startCost = groundCost(entryNav.PathLength);
if (!reachable)
startCost += 100000.0f; // Tier 2 only: deferred roots rank after reachable roots
}
@@ -960,9 +1054,9 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
continue;
uint32 const startIndex = candidate.Edge.Reverse
? candidate.Count - 1 - candidate.Index : candidate.Index;
float newCost = (alreadyThere ? 0.0f : nav.PathLength) +
float newCost = (alreadyThere ? 0.0f : groundCost(nav.PathLength)) +
roadSectionCost(candidate.Edge.DatabaseId, candidate.Edge.Reverse,
startIndex, candidate.Count - 1, candidate.Edge.Cost);
startIndex, candidate.Count - 1, edgeCost(candidate.Edge));
auto old = distance.find(key);
if (old != distance.end() && old->second <= newCost)
continue;
@@ -972,7 +1066,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
previous.erase(key);
virtualRoots[key] = { candidate.Edge.DatabaseId, candidate.Index,
candidate.Edge.Reverse, candidate.Point,
alreadyThere ? 0.0f : nav.PathLength };
alreadyThere ? 0.0f : groundCost(nav.PathLength) };
open.push({ newCost, state });
++accepted;
}
@@ -983,10 +1077,92 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
if (!startCount && !accepted)
{
if(std::isfinite(directFlightCost))return directFlightRoute();
error = "no reachable travel node or recorded ground edge near current position";
return false;
}
// Reserve a bounded part of the flight budget for complete local portal
// connections before speculative road exits spend it. Nearby ground roots
// are entry options, not a reason to overlook flight-only portal islands.
if (flightEnabled && !locationGoal)
{
struct PortalConnection
{
float Rank;
std::string Root;
ClientlessTravelEdge Edge;
};
std::vector<PortalConnection> connections;
auto const& goal = destinationItr->second;
for (auto const& outgoing : _edges)
for (auto const& edge : outgoing.second)
{
if (edge.Type != "portal" && edge.Type != "portal_use") continue;
if (!RescueEdgeAllowed(edge) || !sAdventureAreaMgr->AllowsEdge(player,edge) ||
(blockedEdgeIds && edge.DatabaseId && blockedEdgeIds->count(edge.DatabaseId))) continue;
auto const& source = _nodes.at(edge.FromNode);
auto const& target = _nodes.at(edge.ToNode);
if (target.MapId != goal.MapId) continue;
float dx=goal.X-target.X,dy=goal.Y-target.Y,dz=goal.Z-target.Z;
float finish = flightDistanceCost(std::sqrt(dx*dx+dy*dy+dz*dz));
for (auto const& root : states)
{
auto const& origin = _nodes.at(root.second.Node);
if (origin.MapId != source.MapId || origin.Key == source.Key) continue;
auto originEdges = _edges.find(origin.Key);
if (originEdges != _edges.end() && std::any_of(originEdges->second.begin(),originEdges->second.end(),
[](ClientlessTravelEdge const& e){return e.Type=="portal" || e.Type=="portal_use";})) continue;
dx=source.X-origin.X;dy=source.Y-origin.Y;dz=source.Z-origin.Z;
float direct=std::sqrt(dx*dx+dy*dy+dz*dz);
if (direct > 1500.0f) continue;
connections.push_back({distance.at(root.first)+flightDistanceCost(direct)+edgeCost(edge)+finish,root.first,edge});
}
}
std::sort(connections.begin(),connections.end(),[](PortalConnection const& a,PortalConnection const& b)
{
if(a.Rank!=b.Rank)return a.Rank<b.Rank;
if(a.Edge.DatabaseId!=b.Edge.DatabaseId)return a.Edge.DatabaseId<b.Edge.DatabaseId;
return a.Root<b.Root;
});
auto const callsBefore = trace.FlightCalls;
unsigned attempts=0;
for (auto const& candidate : connections)
{
if (++attempts>8 || trace.FlightCalls-callsBefore>=6) break;
auto const edge=candidate.Edge;
auto const& origin=_nodes.at(states.at(candidate.Root).Node);
auto const& source=_nodes.at(edge.FromNode);
auto const& target=_nodes.at(edge.ToNode);
if (blockedBridgeKeys && (blockedBridgeKeys->count("flight:"+origin.Key+"->"+source.Key) ||
blockedBridgeKeys->count("flight:"+target.Key+"->"+goal.Key))) continue;
float entry=flightCost(origin,source.MapId,source.X,source.Y,source.Z,true);
if (!std::isfinite(entry)) continue;
float finish=target.Key==goal.Key?0.0f:flightCost(target,goal.MapId,goal.X,goal.Y,goal.Z,true);
if (!std::isfinite(finish)) continue;
auto relax=[&](std::string const& from,ClientlessTravelNode const& to,
std::string const& type,uint32 id,bool reverse,float cost)
{
State next{to.Key,false};auto key=stateKey(next);
float score=distance.at(from)+cost;
auto old=distance.find(key);
if(old==distance.end() || score<old->second)
{
distance[key]=score;states[key]=next;persistentHops[key]=persistentHops[from]+1;
previous[key]={from,states.at(from).Node,type,id,reverse};
open.push({score,next});
}
return key;
};
auto sourceKey=relax(candidate.Root,source,"free_flight",0,false,entry);
auto targetKey=relax(sourceKey,target,edge.Type,edge.DatabaseId,edge.Reverse,edgeCost(edge));
if(target.Key!=goal.Key)relax(targetKey,goal,"free_flight",0,false,finish);
TC_LOG_INFO("server.adventurebot.navigation",
"PORTAL_FLIGHT_CONNECTION from='%s' source='%s' target='%s' goal='%s' entry=%.1f finish=%.1f",
origin.Key.c_str(),source.Key.c_str(),target.Key.c_str(),goal.Key.c_str(),entry,finish);
}
}
// C8.20: virtual node bridges are equal-cost ground alternatives and
// participate even when stored graph routes also exist.
bool const repairRequired = true;
@@ -1135,13 +1311,13 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
candidate.EdgeId = edge.DatabaseId;
candidate.Reverse = edge.Reverse;
candidate.EndIndex = i;
candidate.RoadCost = edge.Cost * (travelled / totalLength);
candidate.RoadCost = edgeCost(edge) * (travelled / totalLength);
candidate.DistanceSq = dsq;
candidate.Point = point;
bool const groundExitExhausted = blockedBridgeKeys &&
blockedBridgeKeys->count("ground_exit_edge:" + identity);
candidate.LocalCost = locationGoal && !groundExitExhausted && dsq <= locationAccessRadius*locationAccessRadius
? std::sqrt(dsq) : std::numeric_limits<float>::infinity();
? groundCost(std::sqrt(dsq)) : std::numeric_limits<float>::infinity();
// A recorded edge can contain hundreds of samples. Calling
// FlightMgr::Estimate for every sample made the synchronous
@@ -1158,10 +1334,9 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
if (flightEnabled && !(blockedBridgeKeys && blockedBridgeKeys->count("flight_exit:" + exitKey)))
{
float const direct = std::sqrt(dsq);
// Forty plus 0.4 * distance is the cheapest possible
// free-flight cost. It is an admissible ordering key;
// rejected area segments simply advance to the next item.
rankedFlightExits.push_back({ candidate.RoadCost + 40.0f + direct * 0.4f,
// Use this journey's flight factor for both candidate
// ordering and execution cost, including expensive modes.
rankedFlightExits.push_back({ candidate.RoadCost + flightDistanceCost(direct),
std::move(candidate) });
}
}
@@ -1217,32 +1392,40 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
uint32 StartIndex = 0;
ClientlessTravelEdgePoint EntryPoint;
} bestExit;
float bestGoalCost = std::numeric_limits<float>::infinity();
float bestGoalCost = directFlightCost;
std::string bestGoalState;
bool bestFinalFlight = false;
// Free flight is a macro connector, not a reason to turn every graph map
// into a complete directed graph. A flyer only needs direct links to the
// requested destination and to nodes that enter a map-changing or physical
// transport edge (portal, ship, zeppelin or tram). Taxi nodes are excluded:
// a free flyer can already reach their same-map destination directly, and
// adding every taxi source recreates an almost-complete graph. Ordinary
// same-map ground
// nodes remain reachable through their recorded graph, while a location
// goal is evaluated directly from every expanded node below.
std::unordered_set<std::string> flightConnectorNodes;
flightConnectorNodes.insert(destinationNode);
for (auto const& edgeGroup : _edges)
for (ClientlessTravelEdge const& edge : edgeGroup.second)
// Every permitted node is a potential way back into the persistent graph.
// Do not materialize an all-to-all air graph, though: candidates are ranked
// on demand below and the shared per-plan Estimate cap remains authoritative.
// This is important for isolated anchors such as the capital Cataclysm
// portals, but is deliberately not portal-specific.
auto flightRemainderLowerBound = [&](ClientlessTravelNode const& candidate)
{
ClientlessTravelNode const& destination = destinationItr->second;
auto lowerBoundToGoal = [&](ClientlessTravelNode const& node)
{
auto fromItr = _nodes.find(edge.FromNode);
auto toItr = _nodes.find(edge.ToNode);
bool const changesMap = fromItr != _nodes.end() && toItr != _nodes.end() &&
fromItr->second.MapId != toItr->second.MapId;
bool const physicalTransport = edge.Type == "portal" || edge.Type == "ship" ||
edge.Type == "zeppelin" || edge.Type == "tram" || edge.Type == "transport";
if (changesMap || physicalTransport)
flightConnectorNodes.insert(edge.FromNode);
}
if (node.MapId != destination.MapId)
return std::numeric_limits<float>::infinity();
float dx = destination.X-node.X, dy = destination.Y-node.Y, dz = destination.Z-node.Z;
return flightDistanceCost(std::sqrt(dx*dx+dy*dy+dz*dz));
};
float best = lowerBoundToGoal(candidate);
auto edges = _edges.find(candidate.Key);
if (edges != _edges.end())
for (ClientlessTravelEdge const& edge : edges->second)
{
if ((!RescueEdgeAllowed(edge) || !sAdventureAreaMgr->AllowsEdge(player,edge)))
continue;
if (blockedEdgeIds && edge.DatabaseId && blockedEdgeIds->count(edge.DatabaseId))
continue;
auto next = _nodes.find(edge.ToNode);
if (next == _nodes.end())
continue;
best = std::min(best, edgeCost(edge) + lowerBoundToGoal(next->second));
}
return best;
};
// 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.
@@ -1272,7 +1455,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
if (fromNodeItr != _edges.end())
for (ClientlessTravelEdge const& edge : fromNodeItr->second)
if (edge.DatabaseId == exit.EdgeId && edge.Reverse == exit.Reverse)
fullEdgeCost = edge.Cost;
fullEdgeCost = edgeCost(edge);
if (fullEdgeCost <= 0.0f)
continue;
float const partialCost = roadSectionCost(exit.EdgeId,
@@ -1335,7 +1518,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
std::string const finishKey = "finish:" + candidateNode.Key;
float const finalCost = blockedBridgeKeys && blockedBridgeKeys->count(finishKey)
? std::numeric_limits<float>::infinity() :
std::min(direct<=locationAccessRadius ? direct : std::numeric_limits<float>::infinity(),air);
std::min(direct<=locationAccessRadius ? groundCost(direct) : std::numeric_limits<float>::infinity(),air);
if (current.Cost + finalCost < bestGoalCost)
{
bestGoalCost = current.Cost + finalCost;
@@ -1379,11 +1562,22 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
// ground transit. Once reached during a planned route, its only legal
// continuation is the portal edge itself. Physical AreaTriggers may
// fire even before the route executor advances to that edge.
bool const isPortalSource =
current.Value.Node.rfind("portal_at_", 0) == 0 &&
current.Value.Node.size() >= 7 &&
current.Value.Node.compare(current.Value.Node.size() - 7, 7, "_source") == 0;
bool const portalArrival = isPortalSource && previous.find(currentKey) != previous.end();
auto const sourceEdges = _edges.find(current.Value.Node);
bool const isPortalSource = sourceEdges != _edges.end() &&
std::any_of(sourceEdges->second.begin(), sourceEdges->second.end(),
[](ClientlessTravelEdge const& edge)
{ return edge.Type == "portal" || edge.Type == "portal_use"; });
// Root states are physical arrivals too: replanning while standing at
// a portal must not turn its source into an indoor flight departure.
// If the portal failed or is forbidden, retain its recorded escape roads.
bool const portalArrival = isPortalSource &&
std::any_of(sourceEdges->second.begin(), sourceEdges->second.end(),
[&](ClientlessTravelEdge const& edge)
{
return (edge.Type == "portal" || edge.Type == "portal_use") &&
RescueEdgeAllowed(edge) && sAdventureAreaMgr->AllowsEdge(player,edge) &&
!(blockedEdgeIds && edge.DatabaseId && blockedEdgeIds->count(edge.DatabaseId));
});
// Persistent TravelGraph edges always reset the bridge guard.
auto edgeItr = _edges.find(current.Value.Node);
@@ -1393,7 +1587,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
for (ClientlessTravelEdge const& edge : edgeItr->second)
{
if ((!RescueEdgeAllowed(edge) || !sAdventureAreaMgr->AllowsEdge(player,edge))) continue;
if (portalArrival && edge.Type != "portal")
if (portalArrival && edge.Type != "portal" && edge.Type != "portal_use")
continue;
if (blockedEdgeIds && edge.DatabaseId && blockedEdgeIds->count(edge.DatabaseId))
continue;
@@ -1401,7 +1595,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
State next{ edge.ToNode, false };
std::string const nextKey = stateKey(next);
float const candidate = current.Cost + edge.Cost;
float const candidate = current.Cost + edgeCost(edge);
auto old = distance.find(nextKey);
if (old != distance.end() && candidate >= old->second)
@@ -1421,15 +1615,15 @@ 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)
if (flightEnabled && !portalArrival && trace.FlightCalls < MaxFlightEstimatesPerPlan)
{
auto const& origin=_nodes.at(current.Value.Node);
for (std::string const& targetKey : flightConnectorNodes)
std::vector<std::pair<float, std::string>> macroFlightCandidates;
std::vector<std::pair<float, std::string>> flightCandidates;
flightCandidates.reserve(_nodes.size());
for (auto const& candidate : _nodes)
{
auto targetItr = _nodes.find(targetKey);
if (targetItr == _nodes.end())
continue;
auto const& target=targetItr->second;
auto const& target=candidate.second;
if (target.Key==origin.Key || target.MapId!=origin.MapId) continue;
State next{target.Key,false};std::string key=stateKey(next);
auto old=distance.find(key);
@@ -1437,10 +1631,62 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
// Even a preferred legal flight costs at least this much.
// Skip expensive terrain sampling when it cannot improve the
// route already found; this preserves the full graph search.
float lowerBound=40.0f+std::sqrt(dx*dx+dy*dy+dz*dz)*0.4f;
float lowerBound=flightDistanceCost(std::sqrt(dx*dx+dy*dy+dz*dz));
if(old!=distance.end() && old->second<=current.Cost+lowerBound)continue;
if ((!RescueNodeAllowed(target) || !sAdventureAreaMgr->AllowsNode(player,target))) continue;
if (blockedBridgeKeys && blockedBridgeKeys->count("flight:"+origin.Key+"->"+target.Key)) continue;
float const remainder = flightRemainderLowerBound(target);
if (!std::isfinite(remainder))
continue;
bool macroConnector = target.Key == destinationNode;
auto outgoing = _edges.find(target.Key);
if (outgoing != _edges.end())
for (ClientlessTravelEdge const& edge : outgoing->second)
{
auto next = _nodes.find(edge.ToNode);
bool const changesMap = next != _nodes.end() &&
next->second.MapId != target.MapId;
bool const physical = edge.Type == "portal" ||
edge.Type == "portal_use" || edge.Type == "ship" ||
edge.Type == "zeppelin" || edge.Type == "tram" ||
edge.Type == "transport";
if (changesMap || physical)
{
macroConnector = true;
break;
}
}
auto& candidates = macroConnector ? macroFlightCandidates : flightCandidates;
candidates.emplace_back(lowerBound + remainder, target.Key);
}
std::sort(macroFlightCandidates.begin(), macroFlightCandidates.end(),
[&](auto const& left, auto const& right)
{
// Every newly reached portal/transport endpoint must get
// one chance to finish the requested journey before the
// bounded probe window is spent on further connectors.
bool const leftIsDestination = left.second == destinationNode;
bool const rightIsDestination = right.second == destinationNode;
if (leftIsDestination != rightIsDestination)
return leftIsDestination;
return left < right;
});
std::sort(flightCandidates.begin(), flightCandidates.end());
macroFlightCandidates.insert(macroFlightCandidates.end(),
flightCandidates.begin(), flightCandidates.end());
std::uint64_t const callsAtOrigin = trace.FlightCalls;
for (auto const& ranked : macroFlightCandidates)
{
if (trace.FlightCalls >= MaxFlightEstimatesPerPlan ||
trace.FlightCalls - callsAtOrigin >= MaxFlightEstimatesPerOrigin)
break;
auto const& target = _nodes.at(ranked.second);
State next{target.Key,false};std::string key=stateKey(next);
auto old=distance.find(key);
auto const blockedFlight = blockedBridgeKeys &&
blockedBridgeKeys->count("flight:"+origin.Key+"->"+target.Key);
if (blockedFlight)
continue;
float cost=flightCost(origin,target.MapId,target.X,target.Y,target.Z);
if (!std::isfinite(cost)) continue;
float score=current.Cost+cost;
@@ -1464,10 +1710,11 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
// road at an MMAP-reachable waypoint. Only remaining portions of
// stored, directed ground edges are admitted; no off-graph shortcut.
// A portal source may not be treated as an ordinary intermediate node.
bool const futurePortalSource =
from.Key.rfind("portal_at_", 0) == 0 &&
from.Key.size() >= 7 &&
from.Key.compare(from.Key.size() - 7, 7, "_source") == 0;
auto const futureEdges = _edges.find(from.Key);
bool const futurePortalSource = futureEdges != _edges.end() &&
std::any_of(futureEdges->second.begin(), futureEdges->second.end(),
[](ClientlessTravelEdge const& edge)
{ return edge.Type == "portal" || edge.Type == "portal_use"; });
if (!futurePortalSource && !hasPersistentContinuation && persistentHops[currentKey] > 0 &&
!(blockedBridgeKeys &&
blockedBridgeKeys->count(MakeLocalBridgeKey(from.Key, "virtual_ground_entry"))))
@@ -1547,9 +1794,9 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
if (exit.EdgeId != entry.Edge->DatabaseId ||
exit.Reverse != entry.Edge->Reverse || exit.EndIndex <= entryIndex)
continue;
float const score = current.Cost + probe.PathLength + exit.LocalCost +
float const score = current.Cost + groundCost(probe.PathLength) + exit.LocalCost +
roadSectionCost(exit.EdgeId, exit.Reverse, entryIndex,
exit.EndIndex, entry.Edge->Cost);
exit.EndIndex, edgeCost(*entry.Edge));
if (score >= bestGoalCost)
continue;
bestGoalCost = score;
@@ -1561,9 +1808,9 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
std::string const nextKey = stateKey(next);
uint32 const begin = entry.Edge->Reverse
? entry.PointCount - 1 - entry.Index : entry.Index;
float const cost = current.Cost + probe.PathLength +
float const cost = current.Cost + groundCost(probe.PathLength) +
roadSectionCost(entry.Edge->DatabaseId, entry.Edge->Reverse,
begin, entry.PointCount - 1, entry.Edge->Cost);
begin, entry.PointCount - 1, edgeCost(*entry.Edge));
auto old = distance.find(nextKey);
if (old != distance.end() && cost >= old->second)
continue;
@@ -1677,7 +1924,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
// C8.20: dynamic node-to-node MMAP bridges are ordinary local
// ground connections, not penalized fallback routes.
float const candidate = current.Cost + measuredLength;
float const candidate = current.Cost + groundCost(measuredLength);
auto old = distance.find(nextKey);
if (old != distance.end() && candidate >= old->second)
continue;
@@ -1692,6 +1939,8 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
}
}
if(bestGoalState.empty() && std::isfinite(directFlightCost))return directFlightRoute();
if ((locationGoal || flightEnabled) && bestExit.Active && bestExit.RootPartial &&
!bestGoalState.empty())
previous.erase(bestGoalState);
@@ -2042,8 +2291,11 @@ bool ClientlessTravelGraphMgr::PlanRouteToPosition(
std::vector<ClientlessTravelRouteStep>& route,
std::string& anchorNode,
std::string& error,
std::unordered_set<uint32> const* blockedEdgeIds)
std::unordered_set<uint32> const* blockedEdgeIds,
ClientlessTravelCost::Profile const* journeyCosts)
{
ClientlessTravelCost::Profile const defaults;
auto const& costs=journeyCosts?*journeyCosts:defaults;
route.clear();
anchorNode.clear();
error.clear();
@@ -2112,8 +2364,8 @@ bool ClientlessTravelGraphMgr::PlanRouteToPosition(
if (localStartDistance > startRadius)
continue;
distance[node.Key] = localStartDistance;
open.push({ localStartDistance, node.Key });
distance[node.Key] = costs.Cost(ClientlessTravelCost::Ground,localStartDistance);
open.push({ distance[node.Key], node.Key });
++startCandidateCount;
}
@@ -2143,7 +2395,7 @@ bool ClientlessTravelGraphMgr::PlanRouteToPosition(
blockedEdgeIds->find(edge.DatabaseId) != blockedEdgeIds->end())
continue;
float const candidate = current.Cost + edge.Cost;
float const candidate = current.Cost + costs.Cost(edge.Type,edge.Cost);
if (candidate >= distance[edge.ToNode])
continue;
@@ -12,6 +12,7 @@
#include "Common.h"
#include "ClientlessGraphProbeCache.h"
#include "ClientlessTravelCost.h"
#include <string>
#include <unordered_map>
@@ -114,13 +115,16 @@ public:
bool Reload();
bool EnsureLoaded();
ClientlessTravelCost::Profile NewJourneyCosts() const;
bool PlanRoute(
Player const* player,
std::string const& destinationNode,
float startRadius,
std::vector<ClientlessTravelRouteStep>& route,
std::string& error,
std::unordered_set<uint32> const* blockedEdgeIds = nullptr);
std::unordered_set<uint32> const* blockedEdgeIds = nullptr,
ClientlessTravelCost::Profile const* journeyCosts = nullptr);
bool PlanRouteToPosition(
Player const* player,
@@ -133,7 +137,8 @@ public:
std::vector<ClientlessTravelRouteStep>& route,
std::string& anchorNode,
std::string& error,
std::unordered_set<uint32> const* blockedEdgeIds = nullptr);
std::unordered_set<uint32> const* blockedEdgeIds = nullptr,
ClientlessTravelCost::Profile const* journeyCosts = nullptr);
// C8.13b: transient local bridges connect nearby graph islands for one
// route plan. Bridges are never persisted and may never follow another
@@ -151,7 +156,8 @@ public:
std::string& error,
std::unordered_set<uint32> const* blockedEdgeIds = nullptr,
std::unordered_set<std::string> const* blockedBridgeKeys = nullptr,
ClientlessTravelLocationGoal const* locationGoal = nullptr);
ClientlessTravelLocationGoal const* locationGoal = nullptr,
ClientlessTravelCost::Profile const* journeyCosts = nullptr);
bool FindNearestNodeToPosition(
uint32 mapId,
@@ -177,9 +183,12 @@ public:
static bool ResolvePortalUse(uint32 spawnId, ClientlessPortalUse& portal, std::string& error);
ClientlessPortalUse const* GetPortalUse(uint32 edgeDatabaseId) const;
bool PortalNativePrerequisites(Player const* player,uint32 edgeDatabaseId) const;
bool AdventurePortalUnlockAllowed(Player const* player,uint32 edgeDatabaseId) const;
bool PortalPrerequisites(Player const* player,uint32 edgeDatabaseId) const;
private:
void RebuildEdgeDistances();
std::string FindNearestStartNode(Player const* player, float maxDistance) const;
ClientlessGraphProbeCache _positionProbes;
@@ -540,6 +540,7 @@ bool BattlefieldWG::SetupBattlefield()
void BattlefieldWG::OnBattleStart()
{
sBattlefieldMgr->UpdateWintergraspFlight(m_Map);
// Spawn titan relic
if (GameObject* relic = SpawnGameObject(GO_WINTERGRASP_TITAN_S_RELIC, WintergraspRelicPos, WintergraspRelicRot))
{
@@ -628,6 +629,7 @@ void BattlefieldWG::UpdateCounterVehicle(bool init)
void BattlefieldWG::OnBattleEnd(bool endByTimer)
{
sBattlefieldMgr->UpdateWintergraspFlight(m_Map);
// Remove relic
if (m_titansRelicGUID)
if (GameObject* relic = GetGameObject(m_titansRelicGUID))
@@ -193,8 +193,8 @@ bool OutdoorPvPZM::SetupOutdoorPvP()
for (uint8 i = 0; i < OutdoorPvPZMBuffZonesNum; ++i)
RegisterZone(OutdoorPvPZMBuffZones[i]);
m_Graveyard = std::make_unique<OPvPCapturePointZM_Graveyard>(this);
AddCapturePoint(m_Graveyard.get()); // though the update function isn't used, the handleusego is!
m_Graveyard = new OPvPCapturePointZM_Graveyard(this);
AddCapturePoint(m_Graveyard); // though the update function isn't used, the handleusego is!
return true;
}
+2 -1
View File
@@ -169,7 +169,8 @@ class OutdoorPvPZM : public OutdoorPvP
void SetHordeTowersControlled(uint32 count);
private:
std::unique_ptr<OPvPCapturePointZM_Graveyard> m_Graveyard;
// Owned by OutdoorPvP::m_capturePoints after AddCapturePoint.
OPvPCapturePointZM_Graveyard* m_Graveyard = nullptr;
uint32 m_AllianceTowersControlled;
uint32 m_HordeTowersControlled;
@@ -3980,6 +3980,7 @@ Appender.Console=1,3,0
Appender.Server=2,2,0,Server.log,w
Appender.GM=2,2,15,gm/gm_%s.log
Appender.DBErrors=2,2,0,DBErrors.log
Appender.AdventureBotNavigation=2,2,7,adventurebot-navigation.log,a,67108864
# Logger config values: Given a logger "name"
# Logger.name
@@ -4005,6 +4006,7 @@ Logger.commands.gm=3,Console GM
Logger.scripts.hotswap=3,Console Server
Logger.sql.sql=5,Console DBErrors
Logger.sql.updates=3,Console Server
Logger.server.adventurebot.navigation=2,AdventureBotNavigation
#Logger.mmaps=3,Server
#Logger.achievement=3,Console Server
@@ -4394,3 +4396,18 @@ Clientless.Browser.Battleground.QueueEnabled = 1
Clientless.Browser.Battleground.JoinEnabled = 0
# Explicit isolated test override. Does not override disabled Queue/Join switches.
Clientless.Browser.AllowBotOnlyTests = 0
# Clientless travel route scoring: measured path length (yards), not ticket
# price or the legacy travel_edge.cost column. Higher factors discourage a
# mode. Portal transitions have a unit base cost (no fictitious map distance).
# Variation draws independent positive mode factors once per journey; recovery
# retains the draw and group members inherit their leader's profile and route.
Clientless.Travel.CostMultiplier.Ground = 1.0
Clientless.Travel.CostMultiplier.Taxi = 1.0
Clientless.Travel.CostMultiplier.Flight = 1.0
Clientless.Travel.CostMultiplier.Ship = 1.0
Clientless.Travel.CostMultiplier.Zeppelin = 1.0
Clientless.Travel.CostMultiplier.Tram = 1.0
Clientless.Travel.CostMultiplier.Portal = 1.0
# 0 disables variation; 0.20 means a per-mode range of 80%..120% per journey.
Clientless.Travel.CostVariation = 0.20
@@ -23,6 +23,7 @@ code=r'''
#include <set>
using uint32=unsigned;
#define TC_GAME_API
#define TC_LOG_WARN(...) ((void)0)
constexpr float INVALID_HEIGHT=-100000;
constexpr int LINEOFSIGHT_ALL_CHECKS=0,CMSG_AREATRIGGER=0;
namespace VMAP{enum class ModelIgnoreFlags{None};}
@@ -100,6 +101,11 @@ int main(){
travel.status.State=ClientlessTravelState::Arrived;leader.mounted=true;mgr.Follow(&a);assert(a.mounted);leader.mounted=false;a=Player(26); // retain a clean origin
// A ground-only bot waits safely when the leader gains excessive height.
leader.flying=true;leader.m_positionZ=80;starts=travel.starts;mgr.Follow(&a);assert(travel.starts==starts);leader.flying=false;leader.m_positionZ=0;
// A shared/action-owned catch-up corridor survives a leader landing ahead.
flight.status.State=ClientlessFlightState::Flying;flight.status.FollowingPlayer=false;
mgr.Observe(&a,1);leader.flying=false;auto previousLands=flight.lands;
assert(mgr.Follow(&a)&&flight.lands==previousLands);
flight.status.State=ClientlessFlightState::Arrived;mgr.Follow(&a);
// A native-capable follower uses flight, and safely lands after a kick.
flight.enabled=true;leader.flying=true;mgr.Follow(&a);assert(flight.follows==1 && mgr.Airborne(&a));a.g=nullptr;mgr.Follow(&a);assert(flight.lands==1);a.g=&group;flight.status.State=ClientlessFlightState::Arrived;leader.flying=false;flight.enabled=false;mgr.Follow(&a);
// A stale owned ground catch-up route cannot suppress a visible-phase
@@ -22,6 +22,19 @@ assert "r.GroupOverride" in follower_hold
assert "r.Dirty=false" in follower_hold
assert "GROUP_MEMBER_FALLBACK_HOLD" in follower_hold
begin_start = source.index("void AdventureBehaviorMgr::Begin(")
begin_end = source.index("void AdventureBehaviorMgr::Fallback(", begin_start)
begin = source[begin_start:begin_end]
assert '"portal_at_"+std::to_string(r.Instance.Entry)+"_source"' in begin
assert 'Queue(player,r,"travel_to_node","node"' in begin
update_start = source.index("void AdventureBehaviorMgr::Update(Player * player")
update_end = source.index("void AdventureBehaviorMgr::FinishInstance(", update_start)
update = source[update_start:update_end]
assert 'arrivalContract="native-area-trigger"' in update
assert "player->IsInAreaTriggerRadius(entrance)" in update
assert "INTENT_ARRIVAL_REJECT" in update
protect_start = source.index("bool AdventureBehaviorMgr::ProtectGroupLeave(")
protect_end = source.index("void AdventureBehaviorMgr::InterruptForInteraction(", protect_start)
protection = source[protect_start:protect_end]
@@ -35,4 +48,9 @@ for invariant in (
):
assert invariant in protection
print("Instance visit retention, inherited group-target hold and formation leave protection passed.")
flight = (root / "src/server/game/World/ClientlessFlightMgr.cpp").read_text()
assert "OpenColumnFrom" in flight
assert "overheadSurface" in flight
assert "flight-departure-overhead-surface" in flight
print("Instance graph entrance/arrival contract, visit retention, flight overhead handling, group-target hold and formation leave protection passed.")
@@ -58,7 +58,7 @@ unsigned urand(unsigned a,unsigned){return a;}float roll=0;float frand(float,flo
namespace ObjectAccessor {std::unordered_map<ObjectGuid,Player*> players;Player*FindPlayer(ObjectGuid id){auto it=players.find(id);return it==players.end()?nullptr:it->second;}}
struct Profile {std::string Locale="deDE";std::unordered_map<std::string,float> Personality;};
struct Lua {std::unordered_map<unsigned,Profile> profiles;Profile const*GetAdventureProfile(unsigned id){auto it=profiles.find(id);return it==profiles.end()?nullptr:&it->second;}}lua;auto sClientlessLuaEngine=&lua;
struct Behavior {int interrupts=0;bool protectGroupLeave=false;std::string intent="social",schedule="social:inn";std::string InteractionIntent(Player*){return intent;}std::string InteractionSchedule(Player*){return schedule;}bool ProtectGroupLeave(Player*)const{return protectGroupLeave;}void ResumePersonalPlan(Player*){}void InterruptForInteraction(Player*){++interrupts;}}behavior;auto sAdventureBehaviorMgr=&behavior;
struct Behavior {int interrupts=0;bool protectGroupLeave=false,protectInstance=false;std::string intent="social",schedule="social:inn";std::string InteractionIntent(Player*){return intent;}std::string InteractionSchedule(Player*){return schedule;}bool ProtectGroupLeave(Player*)const{return protectGroupLeave;}bool ProtectInstanceGroupLeave(Player*)const{return protectInstance;}void ResumePersonalPlan(Player*){}void InterruptForInteraction(Player*){++interrupts;}}behavior;auto sAdventureBehaviorMgr=&behavior;
struct Combat {bool busy=false;int assists=0;bool Busy(Player*){return busy;}bool Assist(Player*,Unit*){++assists;return true;}}combat;auto sAdventureCombatMgr=&combat;
struct ClientlessAction{std::string Name;std::unordered_map<std::string,std::string> Parameters;};
struct Core {time_t activity=0;int queued=0;time_t LastActivity(ObjectGuid){return activity;}bool QueueAction(unsigned,ClientlessAction){++queued;return true;}}core;auto sClientlessPlayerMgr=&core;
@@ -133,6 +133,17 @@ int main(){
mgr._records.clear();behavior.protectGroupLeave=true;behavior.schedule="groupformation:meeting";leader.afk=true;
tick(8000);behavior.schedule="instance:dungeon";tick(9000);assert(bot.group);
behavior.protectGroupLeave=false;leader.afk=false;behavior.schedule="adventure:road";tick(9001);assert(!bot.group);bot.group=&group;
// Instance travel/inside protects late arrivals from both AFK and distance.
mgr._records.clear();behavior.schedule="raid:instance";behavior.protectInstance=true;
leader.afk=true;groupTravel.separated=true;tick(10000);tick(12000);assert(bot.group);
// Explicit intent changes retain their existing behavior.
behavior.schedule="profession:city";tick(12001);assert(!bot.group);
bot.group=&group;behavior.schedule="raid:instance";tick(13000);
behavior.protectInstance=false;leader.afk=false;groupTravel.separated=false;
tick(13599);assert(bot.group);tick(13600);assert(!bot.group);
bot.group=&group;behavior.protectInstance=true;groupTravel.separated=true;tick(14000);
behavior.protectInstance=false;tick(14001);assert(!bot.group);
bot.group=&group;groupTravel.separated=false;
// Explicit diagnostic leave uses ordinary safe-state guards.
bot.combat=true;assert(!mgr.RequestLeave(&bot)&&bot.group);bot.combat=false;
assert(mgr.RequestLeave(&bot)&&!bot.group);
@@ -0,0 +1,30 @@
"""Verify delayed dynamic bridge rejection blocks only its directed connection."""
from pathlib import Path
import subprocess, tempfile
root = Path(__file__).resolve().parents[2]
source = (root / "src/server/game/World/ClientlessActionExecutorMgr.cpp").read_text()
start = source.index(' if (!failedEdgeDatabaseId && reason.rfind("route-local-", 0) == 0 &&')
end = source.index(' // A failed initial local approach', start)
code = r'''
#include <string>
#include <vector>
#include <unordered_set>
#include <cassert>
struct Step {std::string NodeKey,EdgeType;};
struct Record {unsigned RouteStep;std::vector<Step> Route;std::unordered_set<std::string> FailedBridgeKeys;};
void reject(Record& record,unsigned failedEdgeDatabaseId,std::string reason){
''' + source[start:end] + r'''
}
int main(){Record r{1,{{"taxi_2","start"},{"stormwind_tradecenter","local_bridge"}}, {}};
reject(r,0,"route-local-PathRejected");assert(r.FailedBridgeKeys.count("taxi_2->stormwind_tradecenter"));
assert(!r.FailedBridgeKeys.count("stormwind_tradecenter->taxi_2"));
r.FailedBridgeKeys.clear();reject(r,15,"route-local-PathRejected");assert(r.FailedBridgeKeys.empty());
r.RouteStep=0;reject(r,0,"route-local-PathRejected");assert(r.FailedBridgeKeys.empty());
r.RouteStep=1;r.Route[1].EdgeType="free_flight";reject(r,0,"route-local-PathRejected");assert(r.FailedBridgeKeys.empty());}
'''
with tempfile.TemporaryDirectory(prefix="vcore-dynamic-bridge-") as directory:
path=Path(directory)
(path/"test.cpp").write_text(code)
subprocess.run(["c++","-std=c++17",str(path/"test.cpp"),"-o",str(path/"test")],check=True)
subprocess.run([str(path/"test")],check=True)
print("Delayed bridge rejection, direction, persistent edges and non-bridge exclusions PASS")
+67
View File
@@ -0,0 +1,67 @@
"""Exercise elevated landings with production geometry and solid overhangs."""
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;
// 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;trail.LandingReady=true;trail.Landing=goal;
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)
@@ -61,6 +61,7 @@ bool ClearRay(Player*,Point const&a,Point const&b){
'''+column+r'''
struct ClientlessFlightMgr{
std::unordered_map<unsigned,Record>_records;Surface surface{0,300,true};int approached=0;bool seek=false;
void Store(unsigned guid,Record&& record){_records[guid]=std::move(record);}
Surface Sample(Player*,Record&,float,float){return surface;}
bool ApproachDeparture(Player*,Record&,bool);bool FindReturnLanding(Player*,Record&);
bool Prepare(Player*player,Record&record,std::string&error){auto origin=surface;bool following=false;
+51 -6
View File
@@ -10,6 +10,7 @@ b = source.index('bool ClientlessFlightMgr::Command(', a)
update = source[a:b]
harness = r'''
#include "ClientlessFlightPath.h"
#include "ClientlessFlightProgress.h"
#include <cassert>
#include <memory>
#include <cstdint>
@@ -24,7 +25,7 @@ struct Position {
float GetExactDist(Position const&p){return std::hypot(std::hypot(x-p.x,y-p.y),z-p.z);}
};
Point PointOf(Position const& p){return {p.x,p.y,p.z};}
struct Spline {int remaining=500; int timeRemaining()const{return remaining;}};
struct Spline {int remaining=1100; int timeRemaining()const{return remaining;}};
struct Player:Position {
Spline spline; Spline* movespline=&spline;
bool alive=true,fly=true,running=true;int stops=0;
@@ -43,14 +44,18 @@ 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;
ClientlessFlightProgress::Watch Progress;uint32 StallRecoveries=0;SharedJoin Join;
Position LastPosition,Start,Segment{25,0,60};Settings Settings;
std::shared_ptr<int> 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<SharedTrail> Published=std::make_shared<SharedTrail>();std::vector<Position> 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,15 +69,24 @@ 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(){
// Small real movements accumulate; oscillating distance does not reset best progress.
ClientlessFlightProgress::Watch watch;watch.Begin(100,120000);
for(int i=0;i<59;++i)watch.Observe(100+(i%2?0.1f:-0.1f),1000);
assert(!watch.Stalled());watch.Observe(100,1000);assert(watch.Stalled());
watch.Observe(99.4f,200);assert(!watch.Stalled()&&watch.IdleMs==0);
// Vertical movement is measured with the same 3D distance as horizontal flight.
watch.Begin(Distance({0,0,0},{0,0,200}),20000);
watch.Observe(Distance({0,0,1},{0,0,200}),1000);assert(watch.IdleMs==0);
ClientlessFlightMgr mgr;mgr._records[26]={};
mgr._records[26].Progress.Begin(25,2000);
// 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._records[26].CheckTimer=0;player.spline.remaining=1900;
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;
@@ -87,14 +101,45 @@ int main(){
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);
// Long flights keep running when actual progress continues, even overdue.
r.Returning=false;r.Moving=true;player.running=true;player.spline.remaining=3000;
r.Progress.Begin(1000,20000);r.Segment={1007,0,60};
for(int i=0;i<90;++i){player.x+=1;r.CheckTimer=0;mgr.Update(1000);}
assert(mgr.failures==0&&r.Progress.Overdue()&&r.Progress.IdleMs==0);
// A genuinely stuck spline survives 59s, then replans locally at 60s.
r.Progress.Begin(Distance(PointOf(player),PointOf(r.Segment)),20000);r.StallRecoveries=0;
int before=mgr.advances;
for(int i=0;i<59;++i){r.CheckTimer=0;mgr.Update(1000);}
assert(mgr.advances==before&&mgr.failures==0);
r.CheckTimer=0;mgr.Update(1000);
assert(mgr.advances==before+1&&r.StallRecoveries==1&&mgr.failures==0);
player.fly=false;mgr.Update(50);assert(mgr.failures==1);
// A ground departure approach must wait for actual arrival before mounting.
r.Status.State=ClientlessFlightState::Mounting;r.ApproachingDeparture=true;
r.Elapsed=0;r.Departure=player;travel.status.State=ClientlessTravelState::Moving;
mgr.Update(50);assert(mgr.begins==0&&mgr.failures==1);
travel.status.State=ClientlessTravelState::Arrived;mgr.Update(50);assert(mgr.begins==1);
// A new airborne generation must publish its verified landing immediately
// and leave a replacement link for followers already holding the old trail.
r.ApproachingDeparture=false;r.Status.State=ClientlessFlightState::Flying;
r.Returning=false;r.Status.Segments=12;r.Status.Avoidances=12;r.DetachedRecoveryCount=0;
player.fly=true;auto old=r.Published;r.Status.Landing={800,0,0};
mgr.Update(50);assert(old->Aborted&&old->Replacement==r.Published);
assert(r.Published->LandingReady&&r.Published->Landing.X==800);
r.ApproachingDeparture=true;r.Status.State=ClientlessFlightState::Mounting;
// A stale Arrived flag cannot permit mounting away from the selected spot.
r.Departure.x=player.x+10;mgr.Update(50);assert(mgr.failures==2&&mgr.begins==1);
// Repeated genuine stalls exhaust local recovery, without dropping the leader trail.
ClientlessFlightMgr stuck;auto& sr=stuck._records[77];
sr.Following=std::make_shared<int>(1);auto leaderTrail=sr.Following;
sr.Segment={player.x+100,0,60};player.fly=true;player.spline.remaining=3000;
for(int attempt=0;attempt<3;++attempt){
sr.Moving=true;player.running=true;sr.Progress.Begin(100,4000);
for(int i=0;i<60;++i){sr.CheckTimer=0;stuck.Update(1000);}
assert(sr.Following==leaderTrail);
assert(stuck.failures==(attempt==2?1:0));
}
assert(stuck.advances==2&&sr.StallRecoveries==2);
}
'''
with tempfile.TemporaryDirectory(prefix='vcore-flight-handoff-') as folder:
@@ -103,4 +148,4 @@ with tempfile.TemporaryDirectory(prefix='vcore-flight-handoff-') as folder:
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.')
print('Flight handoff, collision, 60s measured stall, jitter, vertical progress, long-flight overrun and local recovery passed.')
+8
View File
@@ -51,6 +51,8 @@ struct Profile{bool CanFreeFly=true;std::vector<Mount> FlightMounts{{}};} profil
struct Lua{auto GetAdventureProfile(uint32){return &profile;}} lua;
struct Config{int reads=0;bool enabled=true;bool GetBoolDefault(const char*,bool){++reads;return enabled;}float GetFloatDefault(const char*,float v){++reads;return v;}std::string GetStringDefault(const char*,const char*v){++reads;return v;}} config;
auto sClientlessLuaEngine=&lua;auto sConfigMgr=&config;
struct DBCManager{static bool IsInArea(uint32 area,uint32 zone){return area==zone || (zone==4197&&area==4538);}};
struct Battlefields{bool war=false;bool IsWintergraspFlightAllowed(uint32 map,uint32 area){return map==571&&DBCManager::IsInArea(area,4197)&&!war;}} battlefield;auto sBattlefieldMgr=&battlefield;
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;
@@ -123,6 +125,12 @@ int main(){
profile.CanFreeFly=false;assert(!mgr.Enabled(&p));
mgr.BeginPlanning();assert(!std::isfinite(mgr.Estimate(&p,0,a,b)));
profile.CanFreeFly=true;
// Static Wintergrasp subareas lack flight flags, but peace permits flight.
mgr.areaCode=4538;mgr.zoneCode=4197;area.fly=false;p.cold=true;
assert(std::isfinite(mgr.Estimate(&p,571,a,b)));
battlefield.war=true;assert(!std::isfinite(mgr.Estimate(&p,571,a,b)));
battlefield.war=false;assert(std::isfinite(mgr.Estimate(&p,571,a,b)));
mgr.areaCode=87;mgr.zoneCode=12;assert(!std::isfinite(mgr.Estimate(&p,571,a,b)));area.fly=true;
int beforeInterrupts=p.interrupts;Record approach;approach.ApproachingDeparture=true;approach.Status.State=ClientlessFlightState::Mounting;mgr.Fail(&p,approach,"interrupted");assert(travel.cancels==1&&!approach.ApproachingDeparture&&approach.Status.State==ClientlessFlightState::Failed&&p.interrupts==beforeInterrupts);
Record revoked;p.canFly=false;mgr.Fail(&p,revoked,"permission lost");assert(revoked.Status.State==ClientlessFlightState::Failed&&!revoked.Returning);
}
@@ -0,0 +1,16 @@
"""Replan during takeoff without prematurely demanding cruise clearance."""
from pathlib import Path
import subprocess, tempfile
root = Path(__file__).resolve().parents[2]
code = r'''
#include "ClientlessFlightPath.h"
#include <cassert>
using namespace ClientlessFlightPath;
int main(){Settings c;Memory m;auto sample=[](float,float){return Surface{94.314f,550,true};};auto ray=[](Point const&,Point const&){return true;};Point goal{-8186,419,94.314f};auto n=Next({-8829,624,94.314f},goal,c,m,sample,ray);assert(n.Valid&&!m.Departed);n=Next({-8826.7f,622.306f,96.4122f},goal,c,m,sample,ray);assert(n.Valid&&!m.Departed);n=Next({-8800,610,104},goal,c,m,sample,ray);assert(n.Valid&&m.Departed);}
'''
with tempfile.TemporaryDirectory(prefix="vcore-takeoff-handoff-") as directory:
path = Path(directory)
(path / "test.cpp").write_text(code)
subprocess.run(["c++", "-std=c++17", "-I" + str(root / "src/server/game/World"), str(path / "test.cpp"), "-o", str(path / "test")], check=True)
subprocess.run([str(path / "test")], check=True)
print("Takeoff handoff at two yards and promotion at real cruise clearance PASS")
@@ -7,13 +7,14 @@ a=s.index('Surface ClientlessFlightMgr::Sample(');b=s.index('\nbool ClientlessFl
code=r'''
#include <cassert>
#include <cmath>
#include <limits>
#include <string>
#include <unordered_map>
#include <set>
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 &map;}int GetPhaseShift(){return 0;}};
namespace PhasingHandler{unsigned swap=719;unsigned GetTerrainMapId(int,unsigned,Terrain*,float,float){return swap;}}
struct TerrainMgr{static inline std::set<unsigned>files{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_map<std::string,bool>Tiles;};
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);
+1 -1
View File
@@ -73,7 +73,7 @@ int main(){
auto highApproach=simulate({0,0,70},{1,0,0},flat,clear);
assert(highApproach.Arrived&&highApproach.Steps<20&&highApproach.Peak<=70);
// Convoy members replay exactly the published segments, retaining their own index.
SharedTrail trail;trail.Points={{20,0,30},{40,0,40}};std::size_t index=0;Settings settings;
SharedTrail trail;trail.LandingReady=true;trail.Landing={800,0,0};trail.Points={{20,0,30},{40,0,40}};std::size_t index=0;Settings settings;
auto shared=FollowTrail({0,0,0},{800,0,0},settings,trail,index,flat,clear);
assert(shared.Step.Valid&&shared.Step.Next.X==20&&index==0);
shared=FollowTrail({20,0,30},{800,0,0},settings,trail,index,flat,clear);
+91 -2
View File
@@ -11,6 +11,7 @@ preamble=r'''
#include "ClientlessGraphProbeCache.h"
#include <chrono>
#include <map>
#include "ClientlessTravelCost.h"
#include <algorithm>
#include <cassert>
#include <cmath>
@@ -34,12 +35,14 @@ struct ClientlessPathDiagnostics {bool Calculated=true, Complete=true,Suspicious
struct Travel {bool complete=true;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);n.Complete=complete;}} travel;
auto sClientlessTravelMgr=&travel;
struct Flight {
bool enabled=false;float minimumX=-10000;
float closedFromX=1e9;bool rejectNearbyTargets=false;bool enabled=false;float minimumX=-10000;float forbiddenTargetX=1e9;
float forbiddenPairFromX=1e9,forbiddenPairTargetX=1e9;
void BeginPlanning(Player const* = nullptr){}
bool Enabled(Player const*){return enabled;}
bool Preferred(Player const*,uint32,Position const&){return false;}
float Estimate(Player const*,uint32 map,Position a,Position b,bool recovery=false){
float d=dist(a,b);return enabled&&map==0&&a.x>=minimumX&&(recovery||d>=60)?(recovery?d:180+d*1.5f):std::numeric_limits<float>::infinity();
bool forbiddenPair=std::fabs(a.x-forbiddenPairFromX)<0.1f&&std::fabs(b.x-forbiddenPairTargetX)<0.1f;
float d=dist(a,b);return enabled&&std::fabs(a.x-closedFromX)>0.1f&&!(rejectNearbyTargets&&b.x>1&&b.x<450)&&map==0&&a.x>=minimumX&&std::fabs(b.x-forbiddenTargetX)>0.1f&&!forbiddenPair&&(recovery||d>=60)?(recovery?d:180+d*1.5f):std::numeric_limits<float>::infinity();
}
} flight;
auto sClientlessFlightMgr=&flight;
@@ -162,6 +165,92 @@ travel.complete=true;route.clear();
assert(accessFlight.PlanAdaptiveRoute(&start,"B",400,200,route,done,endKey,why,nullptr,&deniedFlightRoots));
assert(route.front().NodeKey!="flight_departure");
travel.complete=true;
// Any allowed graph node can be a bounded, on-demand flight re-entry. The
// planner must not require a portal/transport node type or an MMAP bridge.
flight.forbiddenTargetX=2000;
ClientlessTravelGraphMgr genericEntry;node(genericEntry,"A",0);node(genericEntry,"R",500);node(genericEntry,"B",2000);
edge(genericEntry,"R","B",88,10,false,"ground");
travel.complete=false;route.clear();
assert(genericEntry.PlanAdaptiveRoute(&start,"B",1,200,route,done,endKey,why)&&done);
assert(route.size()==3&&route[0].NodeKey=="A"&&route[1].NodeKey=="R");
assert(route[1].EdgeType=="free_flight"&&route[2].EdgeDatabaseId==88);
flight.forbiddenTargetX=1e9;travel.complete=true;
// Keep probe budget for a later graph state: fly to a generic portal source,
// cross it, and then fly to the destination. A rejected direct flight must
// not consume all estimates at the initial node.
flight.forbiddenPairFromX=0;flight.forbiddenPairTargetX=2000;
ClientlessTravelGraphMgr portalEntry;node(portalEntry,"A",0);node(portalEntry,"P",500);
node(portalEntry,"Q",1500);node(portalEntry,"B",2000);
edge(portalEntry,"P","Q",89,1,false,"portal_use");
travel.complete=false;route.clear();
assert(portalEntry.PlanAdaptiveRoute(&start,"B",1,200,route,done,endKey,why)&&done);
assert(route.size()==4&&route[1].NodeKey=="P"&&route[1].EdgeType=="free_flight");
assert(route[2].NodeKey=="Q"&&route[2].EdgeType=="portal_use");
assert(route[3].NodeKey=="B"&&route[3].EdgeType=="free_flight");
flight.forbiddenPairFromX=flight.forbiddenPairTargetX=1e9;travel.complete=true;
// Replanning AT an interactive portal must traverse it, even when flying
// directly from the indoor source looks cheaper to the estimate.
ClientlessTravelGraphMgr rootPortal;node(rootPortal,"P",500);node(rootPortal,"Q",-1000);node(rootPortal,"B",2000);
edge(rootPortal,"P","Q",91,1,false,"portal_use");
Player atPortal;atPortal.x=500;travel.complete=false;route.clear();
assert(rootPortal.PlanAdaptiveRoute(&atPortal,"B",1,200,route,done,endKey,why)&&done);
assert(route.size()>=3&&route[1].EdgeType=="portal_use"&&route[1].NodeKey=="Q");
// A blocked portal does not trap the actor: its recorded escape road remains.
edge(rootPortal,"P","B",92,400,false,"ground");
std::unordered_set<uint32> failedPortal{91};flight.enabled=false;route.clear();
assert(rootPortal.PlanAdaptiveRoute(&atPortal,"B",1,200,route,done,endKey,why,&failedPortal)&&done);
assert(route.back().EdgeDatabaseId==92);
// Many nearby inaccessible anchors must leave budget for an isolated portal
// outside the local-entry radius and the flight from its destination.
flight.enabled=true;flight.rejectNearbyTargets=true;
flight.forbiddenPairFromX=0;flight.forbiddenPairTargetX=2000;
ClientlessTravelGraphMgr crowded;node(crowded,"A",0);node(crowded,"P",500);node(crowded,"Q",1500);node(crowded,"B",2000);
edge(crowded,"P","Q",93,1,false,"portal_use");
for(unsigned i=1;i<=40;++i)node(crowded,"indoor"+std::to_string(i),float(i*10));
route.clear();assert(crowded.PlanAdaptiveRoute(&start,"B",400,200,route,done,endKey,why)&&done);
bool usedIsolatedPortal=false;for(auto const& step:route)if(step.EdgeDatabaseId==93)usedIsolatedPortal=true;
assert(usedIsolatedPortal);
flight.rejectNearbyTargets=false;flight.forbiddenPairFromX=flight.forbiddenPairTargetX=1e9;
// Speculative road exits and an unusable initial departure must not starve a
// nearby flight-only portal. The cheap complete macro route must beat the
// long native fallback, not merely appear as a considered candidate.
flight.enabled=true;flight.closedFromX=0;travel.complete=true;
ClientlessTravelGraphMgr portalBudget;node(portalBudget,"A",0);node(portalBudget,"C",100);
node(portalBudget,"P",500);node(portalBudget,"Q",1500);node(portalBudget,"B",2000);
edge(portalBudget,"P","Q",94,1,false,"portal_use");
edge(portalBudget,"A","B",95,10000,false,"taxi");
for(unsigned i=0;i<4;++i){
auto key="remote"+std::to_string(i);node(portalBudget,key,3000+float(i*100));
edge(portalBudget,"A",key,100+i,10000);
for(float x:{0.f,1000.f,2000.f,3000.f}){ClientlessTravelEdgePoint pt;pt.X=x;pt.Y=float(i*20);portalBudget._edgePoints[100+i].push_back(pt);}
}
route.clear();assert(portalBudget.PlanAdaptiveRoute(&start,"B",150,200,route,done,endKey,why)&&done);
bool budgetPortal=false;for(auto const& step:route)if(step.EdgeDatabaseId==94)budgetPortal=true;
assert(budgetPortal);flight.closedFromX=1e9;
// Apply modal factors to ALL persisted edges and keep a profile stable over
// replanning. Independent journeys may choose different near-equal modes.
flight.enabled=false;travel.complete=true;PathGenerator::available=false;
ClientlessTravelGraphMgr modal;node(modal,"A",0);node(modal,"B",1000);
edge(modal,"A","B",200,1000,false,"ground");edge(modal,"A","B",201,1050,false,"taxi");
bool sawGround=false,sawTaxi=false;
for(unsigned seed=1;seed<=100;++seed){
auto profile=ClientlessTravelCost::Sample({},seed,.2f);
route.clear();assert(modal.PlanAdaptiveRoute(&start,"B",1,200,route,done,endKey,why,nullptr,nullptr,nullptr,&profile)&&done);
auto picked=route.back().EdgeDatabaseId;sawGround|=picked==200;sawTaxi|=picked==201;
route.clear();assert(modal.PlanAdaptiveRoute(&start,"B",1,200,route,done,endKey,why,nullptr,nullptr,nullptr,&profile)&&done);
assert(route.back().EdgeDatabaseId==picked);
}assert(sawGround&&sawTaxi);
// A same-map flight is a competing route, never an unconditional override.
flight.enabled=true;ClientlessTravelCost::Profile expensiveFlight;expensiveFlight.Factors[ClientlessTravelCost::Flight]=100;
ClientlessTravelLocationGoal sameMap;sameMap.X=1000;sameMap.AnchorRadius=5;
route.clear();assert(modal.PlanAdaptiveRoute(&start,"B",1,200,route,done,endKey,why,nullptr,nullptr,&sameMap,&expensiveFlight)&&done);
assert(route.back().NodeKey!="flight_location");
expensiveFlight.Factors[ClientlessTravelCost::Flight]=.25f;
route.clear();assert(modal.PlanAdaptiveRoute(&start,"B",1,200,route,done,endKey,why,nullptr,nullptr,&sameMap,&expensiveFlight)&&done);
assert(route.back().NodeKey=="flight_location"&&route.back().EdgeType=="free_flight");
std::cout<<"ground planner regressions and dynamic flight/node/location/virtual-exit integration passed\n";
}
'''
@@ -18,6 +18,7 @@ def method(signature):
return source[start:end]
preamble = r'''
#include "ClientlessTravelCost.h"
#include <algorithm>
#include <cassert>
#include <cmath>
@@ -77,7 +78,7 @@ struct Flight {
bool enabled=false,starts=false,recovery=false;int launches=0;FlightStatus status;
void BeginPlanning(){}
float Estimate(Player*,uint32,Position const&,Position const&,bool fallback=false){recovery=fallback;return enabled?100:std::numeric_limits<float>::infinity();}
bool Start(Player*,Position const&,std::string&){++launches;return starts;}
bool Start(Player*,Position const&,std::string&,bool=false){++launches;return starts;}
FlightStatus const* GetStatus(uint32){return &status;}
} flight;
auto sClientlessFlightMgr=&flight;
@@ -95,8 +96,8 @@ struct Graph {
} graph;
auto sClientlessTravelGraphMgr=&graph;
struct Areas{bool AllowsNode(Player*,ClientlessTravelNode const&){return true;}bool AllowsSegment(Player*,uint32,Position const&,Position const&){return true;}}areas;auto sAdventureAreaMgr=&areas;
static int recoveries=0;
bool ClientlessActionExecutorMgr::RecoverTravel(Player*,uint32,Record&,uint32,std::string const&){++recoveries;return false;}
static int recoveries=0;static uint32 recoveryEdge=0;
bool ClientlessActionExecutorMgr::RecoverTravel(Player*,uint32,Record&,uint32 edge,std::string const&){++recoveries;recoveryEdge=edge;return false;}
'''
methods = '\n'.join(method(sig) for sig in (
'bool StartGroundConnection(',
@@ -190,8 +191,10 @@ int main(){
// Owner adapters can reject, wait, or shorten a recorded road without
// losing the original index mapping or launching an unsafe spline.
Record adapted;adapted.Active=true;adapted.EdgePath.resize(5);for(unsigned i=0;i<5;++i)adapted.EdgePath[i].X=150+i*10;p.x=140;
mgr._groundAdapters[26]=[](Player*,std::vector<Position>&){return false;};before=p.motion.launches;
adapted.Route.resize(1);adapted.Route[0].EdgeDatabaseId=1885;
mgr._groundAdapters[26]=[](Player*,std::vector<Position>&){return false;};before=p.motion.launches;auto previousRecoveries=recoveries;
assert(!mgr.StartRecordedEdgePath(&p,26,adapted) && p.motion.launches==before);
assert(recoveries==previousRecoveries+1 && recoveryEdge==1885);
mgr._groundAdapters[26]=[](Player*,std::vector<Position>&path){path.resize(1);return true;};
assert(mgr.StartRecordedEdgePath(&p,26,adapted) && adapted.FormationWaiting && p.motion.launches==before);
mgr._groundAdapters[26]=[](Player*,std::vector<Position>&path){path.resize(3);return true;};
@@ -203,5 +206,5 @@ with tempfile.TemporaryDirectory(prefix='vcore-ground-exit-') as folder:
cpp = Path(folder) / 'test.cpp'
binary = Path(folder) / 'test'
cpp.write_text(preamble + structs + cls + doubles + methods + tests)
subprocess.run(['c++','-include',str(Path(__file__).with_name('telemetry_test_stub.h')), '-std=c++17', '-Wall', '-Wextra', '-Wno-unused-parameter', str(cpp), '-o', str(binary)], check=True)
subprocess.run(['c++','-I'+str(root/'src/server/game/World'),'-include',str(Path(__file__).with_name('telemetry_test_stub.h')), '-std=c++17', '-Wall', '-Wextra', '-Wno-unused-parameter', str(cpp), '-o', str(binary)], check=True)
subprocess.run([str(binary)], check=True)
+5 -2
View File
@@ -26,9 +26,9 @@ namespace Movement{struct MoveSplineInit{Player*p;Point target;MoveSplineInit(Pl
enum class ClientlessFlightState{Mounting,Flying,Arrived,Failed};
struct Record{
struct{ClientlessFlightState State=ClientlessFlightState::Flying;bool FollowingPlayer=true;Position Target,Landing;unsigned Segments=0;float Travelled=5000;std::string Detail;}Status;
bool Returning=false,Moving=false;unsigned Elapsed=1000,TargetUpdated=0,SegmentElapsed=0;Position Segment;
bool Returning=false,Moving=false;unsigned Elapsed=1000,TargetUpdated=0,SegmentElapsed=0;Position Segment,Start;unsigned SharedWaitSince=0;std::string AbortReason;
ClientlessFlightPath::Settings Settings;ClientlessFlightPath::Memory Memory;
std::shared_ptr<int>Following;std::vector<Position>Breadcrumbs;
std::shared_ptr<SharedTrail>Following;std::shared_ptr<SharedTrail>Published=std::make_shared<SharedTrail>();std::vector<Position>Breadcrumbs;
};
bool clear=true,valid=true;float ceiling=300;
bool ClearRay(Player*,Point const&,Point const&){return clear;}
@@ -54,6 +54,9 @@ int main(){
p.Relocate(110,20,30);r.Moving=true;assert(mgr.AdvanceFollowing(&p,r));assert(!r.Moving && p.stops==2); // holds in air, never dismounts at moving goal
assert(mgr.LandFollowing(&p));assert(!r.Status.FollowingPlayer && r.Status.Landing.z==0 && r.Status.Travelled==0);
assert(!mgr.Follow(&p,target,error)); // landing cannot be replaced by a new pursuit
r.Status.FollowingPlayer=false;r.Following=std::make_shared<SharedTrail>();
assert(mgr.LandFollowing(&p));assert(r.Returning&&!r.Following&&r.Start.z==0&&r.Breadcrumbs.empty());
r.Returning=false;
r.Status.FollowingPlayer=true;r.Status.State=ClientlessFlightState::Mounting;assert(mgr.LandFollowing(&p) && mgr.failures==1);
r.Status.State=ClientlessFlightState::Flying;r.Status.FollowingPlayer=true;valid=false;assert(mgr.LandFollowing(&p) && mgr.failures==2);
}
@@ -0,0 +1,46 @@
"""Run the production group-wide instance leave protection predicate."""
from pathlib import Path
import subprocess,tempfile
root=Path(__file__).resolve().parents[2]
s=(root/"src/server/game/World/AdventureBehaviorMgr.cpp").read_text()
a=s.index("bool AdventureBehaviorMgr::ProtectInstanceGroupLeave(")
b=s.index("void AdventureBehaviorMgr::InterruptForInteraction",a)
code=r'''
#include "AdventureInstancePolicy.h"
#include <cassert>
#include <unordered_map>
#include <string>
using uint32=unsigned;
namespace GameTime{time_t GetGameTime(){return 1000;}}
struct Group;
struct Map{bool dungeon=false;bool IsDungeon()const{return dungeon;}};
struct Guid{unsigned id;unsigned GetCounter()const{return id;}};
struct Player{unsigned id;Group* group=nullptr;Map map;bool online=true;bool IsInWorld()const{return online;}Group* GetGroup(){return group;}Map* GetMap(){assert(online);return &map;}Guid GetGUID(){return {id};}};
struct Reference{Player* player=nullptr;Reference* following=nullptr;Player* GetSource(){return player;}Reference* next(){return following;}};
struct Group{Reference* first=nullptr;Reference* GetFirstMember(){return first;}};
struct Location{unsigned InstanceMap=671;} location;
struct Locations{Location const* Find(std::string const& key){return key=="instance"?&location:nullptr;}} locations;auto sAdventureLocationMgr=&locations;
struct AdventureBehaviorMgr{struct Record{AdventureInstancePolicy::Visit Instance;std::string Intent,Location;time_t Deadline=0;};std::unordered_map<unsigned,Record> _records;bool ProtectInstanceGroupLeave(Player*)const;};
'''+s[a:b]+r'''
int main(){AdventureBehaviorMgr m;Player late{1},leader{2};assert(!m.ProtectInstanceGroupLeave(&late));
Reference second{&leader,nullptr},first{&late,&second};Group group{&first};late.group=leader.group=&group;
assert(!m.ProtectInstanceGroupLeave(&late));leader.map.dungeon=true;
assert(m.ProtectInstanceGroupLeave(&late)); // human leader inside, no behavior record
leader.online=false;assert(!m.ProtectInstanceGroupLeave(&late));leader.online=true;leader.map.dungeon=false;
auto& r=m._records[2];r.Instance.Active=true;
for(auto phase:{AdventureInstancePolicy::Phase::Travel,AdventureInstancePolicy::Phase::Preparing,AdventureInstancePolicy::Phase::Entering,AdventureInstancePolicy::Phase::Inside,AdventureInstancePolicy::Phase::Returning}){
r.Instance.Stage=phase;assert(m.ProtectInstanceGroupLeave(&late));}
r.Instance.Stage=AdventureInstancePolicy::Phase::Rest;r.Intent="raid";r.Location="instance";r.Deadline=2000;
assert(!m.ProtectInstanceGroupLeave(&late)); // finished visit must not retain protection
r.Instance.Active=false;assert(m.ProtectInstanceGroupLeave(&late)); // pending retry/en-route
r.Intent="instance";assert(m.ProtectInstanceGroupLeave(&late));
r.Deadline=1000;assert(!m.ProtectInstanceGroupLeave(&late));
r.Deadline=2000;r.Location="unknown";assert(!m.ProtectInstanceGroupLeave(&late));
r.Location="instance";r.Intent="profession";assert(!m.ProtectInstanceGroupLeave(&late));
first.following=nullptr;assert(!m.ProtectInstanceGroupLeave(&late));}
'''
with tempfile.TemporaryDirectory(prefix="vcore-instance-group-protection-") as d:
p=Path(d);(p/"test.cpp").write_text(code)
subprocess.run(["c++","-std=c++17","-I"+str(root/"src/server/game/World"),str(p/"test.cpp"),"-o",str(p/"test")],check=True)
subprocess.run([str(p/"test")],check=True)
print("Instance group protection: travel/preparing/entering/inside/return, human leader, offline, retry, expiry and finished visit PASS")
+12 -3
View File
@@ -68,6 +68,8 @@ struct Player {
bool IsInPhase(GameObject*){return phase;}bool CanSeeOrDetect(GameObject*){return visible;}
GameObject* GetGameObjectIfCanInteractWith(int guid){return inRange&&guid==go.guid?&go:nullptr;}
float GetPositionX(){return x;}float GetPositionY(){return y;}float GetPositionZ(){return z;}
unsigned getLevel(){return 85;}
bool TeleportTo(uint32 map,float tx,float ty,float tz,float){pending=true;world=false;mapId=map;x=tx;y=ty;z=tz;return true;}
std::string GetName(){return "Arthoran";}void StopMoving(){}
};
void Session::HandleGameObjectUseOpcode(WorldPacket& packet) {
@@ -80,8 +82,11 @@ void Session::CompleteClientlessTeleport(){
player->mapId=wrongTarget?1:0;player->x=-8998;player->y=861;player->z=29;
}
struct Node {std::string Key;uint32 MapId=530;};
struct Portal {uint32 SpawnId=83,Entry=183325,SpellId=17334;Node Source;};
struct Graph {bool prerequisites=true;bool PortalPrerequisites(Player*,uint32){return prerequisites;}bool available=true;Portal portal;auto GetPortalUse(uint32){return available?&portal:nullptr;}} graph;
struct Portal {uint32 SpawnId=83,Entry=183325,SpellId=17334;Node Source;struct{uint32 MapId=0;float X=-8998,Y=861,Z=29,O=0;}Target;};
struct Graph {bool nativePrerequisites=true,adventureUnlock=false;
bool PortalNativePrerequisites(Player*,uint32){return nativePrerequisites;}
bool AdventurePortalUnlockAllowed(Player*,uint32){return adventureUnlock;}
bool available=true;Portal portal;auto GetPortalUse(uint32){return available?&portal:nullptr;}} graph;
struct Travel {void CancelTravel(uint32){}} travel;
struct Lua {void DismountGroundMount(Player*,const char*){}} lua;
auto sClientlessTravelGraphMgr=&graph;auto sClientlessTravelMgr=&travel;auto sClientlessLuaEngine=&lua;
@@ -105,7 +110,11 @@ harness += methods
harness += r'''
int main(){
ClientlessActionExecutorMgr mgr;
{Player p;Record r;graph.prerequisites=false;assert(!mgr.StartPortalUse(&p,26,r)&&p.session.clicks==0);graph.prerequisites=true;}
{Player p;Record r;graph.nativePrerequisites=false;assert(!mgr.StartPortalUse(&p,26,r)&&p.session.clicks==0);graph.nativePrerequisites=true;}
{Player p;Record r;graph.nativePrerequisites=false;graph.adventureUnlock=true;
assert(mgr.StartPortalUse(&p,26,r)&&p.session.clicks==0&&p.pending);
mgr.UpdatePortalUse(&p,26,r);assert(r.RouteStep==2&&p.session.sequence==1);
graph.nativePrerequisites=true;graph.adventureUnlock=false;}
{Player p;Record r;assert(mgr.StartPortalUse(&p,26,r));assert(p.session.clicks==1&&!p.world);
mgr.UpdatePortalUse(&p,26,r);assert(!r.PortalUseActive&&r.RouteStep==2&&p.mapId==0&&p.session.sequence==1);}
// The global lifecycle may have ACKed a delayed cast before our next update.
@@ -0,0 +1,55 @@
"""Run the production local-flight adapter; preserve macro route and native checks."""
from pathlib import Path
import subprocess
import tempfile
root = Path(__file__).resolve().parents[2]
src = (root/'src/server/game/World/ClientlessActionExecutorMgr.cpp').read_text()
a = src.index('bool ClientlessActionExecutorMgr::TryRouteApproachFlight(')
b = src.index('bool ClientlessActionExecutorMgr::RecoverTravel(', a)
method = src[a:b]
harness = r'''
#include <cassert>
#include <cmath>
#include <string>
#include <vector>
#include <set>
using uint32=unsigned;
struct Position {float x=0,y=0,z=0;void Relocate(float a,float b,float c,float){x=a;y=b;z=c;}};
struct Player {unsigned GetMapId(){return 0;}float GetExactDist(float x,float y,float z){return std::sqrt(x*x+y*y+z*z);}};
struct Step {std::string NodeKey="portal",EdgeType="start";unsigned MapId=0,EdgeDatabaseId=0;float X=100,Y=0,Z=10,O=0;};
enum class ClientlessActionExecutionState{Moving,Failed};
struct Record {std::vector<Step> Route{Step{},Step{}};unsigned RouteStep=0;std::set<std::string> FailedBridgeKeys;bool DeferredStartActive=true,FlightActive=false,Active=false;std::vector<int> EdgePath{1,2};struct {ClientlessActionExecutionState State=ClientlessActionExecutionState::Failed;std::string Detail;}Status;};
struct Flight {bool available=true;unsigned calls=0;Position last;bool Start(Player*,Position const&p,std::string&error,bool owned){assert(owned);++calls;last=p;if(!available)error="no safe landing";return available;}}flight;
auto sClientlessFlightMgr=&flight;
#define TC_LOG_INFO(...) ((void)0)
struct ClientlessActionExecutorMgr {bool TryRouteApproachFlight(Player*,uint32,Record&);};
'''
harness += method + r'''
int main(){
Player p;Record r;ClientlessActionExecutorMgr mgr;
// Fly to the rejected connector, preserving the following portal edge.
assert(mgr.TryRouteApproachFlight(&p,163,r));
assert(r.FlightActive&&r.Active&&!r.DeferredStartActive&&r.EdgePath.empty());
assert(r.RouteStep==0&&r.Route.size()==2&&r.Route[0].EdgeType=="start");
assert(flight.last.x==100&&flight.last.z==10&&flight.calls==1);
assert(!mgr.TryRouteApproachFlight(&p,163,r)&&flight.calls==1);
// No flight permission/solid landing/ground connection means no fallback.
r=Record{};flight.available=false;
assert(!mgr.TryRouteApproachFlight(&p,163,r)&&!r.FlightActive&&r.Route.size()==2);
r=Record{};flight.available=true;r.Route[0].MapId=1;
assert(!mgr.TryRouteApproachFlight(&p,163,r));
r=Record{};r.Route[0].EdgeDatabaseId=42;
assert(!mgr.TryRouteApproachFlight(&p,163,r));
for(auto type:{"portal_use","taxi","free_flight","ship"}){
r=Record{};r.Route[0].EdgeType=type;assert(!mgr.TryRouteApproachFlight(&p,163,r));
}
r=Record{};r.Route[0].EdgeType="local_bridge";assert(mgr.TryRouteApproachFlight(&p,163,r));
}
'''
with tempfile.TemporaryDirectory(prefix='vcore-route-flight-') as folder:
cpp=Path(folder)/'test.cpp'; exe=Path(folder)/'test'
cpp.write_text(harness)
subprocess.run(['c++','-std=c++17',str(cpp),'-o',str(exe)],check=True)
subprocess.run([str(exe)],check=True)
print('Route approach flight: original route retained, one attempt, native refusal and transport boundaries passed.')
@@ -0,0 +1,88 @@
"""Regression: join while leader walks to takeoff; continuous safe trail handoff."""
from pathlib import Path
import subprocess,tempfile
root=Path(__file__).resolve().parents[2]
source=(root/'src/server/game/World/ClientlessFlightMgr.cpp').read_text()
a=source.index(' if(record.Following && !record.Returning)',source.index('bool ClientlessFlightMgr::Advance('))
b=source.index(' Point current =',a)
sync=source[a:b]
a=source.index('void ClientlessFlightMgr::Store(')
b=source.index('bool ClientlessFlightMgr::Begin(',a)
store=source[a:b]
code=r'''
#include "ClientlessFlightPath.h"
#include <cassert>
#include <iostream>
#include <memory>
#include <string>
#include <unordered_map>
using uint32=unsigned;
using namespace ClientlessFlightPath;
struct Position{float x=0,y=0,z=0;void Relocate(float a,float b,float c){x=a;y=b;z=c;}float GetExactDist(Position const&p)const{return std::hypot(std::hypot(x-p.x,y-p.y),z-p.z);}};
struct Record{std::shared_ptr<SharedTrail> Following;std::shared_ptr<SharedTrail> Published=std::make_shared<SharedTrail>();bool Returning=false;unsigned SharedWaitSince=0,Elapsed=0;std::size_t SharedIndex=0;SharedJoin Join;struct{unsigned MapId=0;Position Landing,Target;std::string Detail;}Status;};
int landings=0;void* player=nullptr;void LandFollowing(void*){++landings;}
bool Sync(Record& record){
''' + sync + r'''
return false;
}
struct ClientlessFlightMgr{std::unordered_map<unsigned,Record> _records;void Store(unsigned,Record&&);};
''' + store + r'''
int main(){
ClientlessFlightMgr manager;Record original;original.Status.Target.x=100;
auto previous=original.Published;manager.Store(1,std::move(original));
Record updated;updated.Status.Target.x=100;updated.Published->LandingReady=true;
auto successor=updated.Published;manager.Store(1,std::move(updated));
assert(previous->Aborted&&previous->Replacement==successor);
Record other;other.Status.Target.x=200;manager.Store(1,std::move(other));
assert(successor->Aborted&&!successor->Replacement);
Record r;r.Following=std::make_shared<SharedTrail>();r.Status.Landing.z=777;
assert(Sync(r)&&r.Status.Landing.z==777); // still preparing; no zero landing copied
r.Following->Landing={12,34,827.765625f};r.Following->LandingReady=true;
assert(!Sync(r)&&r.Status.Landing.x==12&&r.Status.Landing.y==34&&r.Status.Landing.z==827.765625f);
assert(r.Published->LandingReady&&r.Published->Landing.Z==827.765625f);
auto replacement=r.Following->Replace({56,78,100});
assert(!Sync(r)&&r.Following==replacement&&r.Status.Landing.z==100);
replacement->Complete=true;assert(!Sync(r)); // completed valid trail remains usable
Record waiting;waiting.Following=std::make_shared<SharedTrail>();
assert(Sync(waiting));waiting.Elapsed=120000;assert(Sync(waiting)&&landings==1);
waiting.Following->Aborted=true;assert(Sync(waiting)&&landings==2);
Settings cfg;auto sample=[](float,float){return Surface{827.765625f,1000,true,0};};
auto clear=[](Point const&,Point const&){return true;};
// The same shared object first exists while its leader is walking to takeoff.
SharedTrail trail;Point landing{0,0,0};std::size_t index=0;
assert(!trail.BindLanding(landing));
auto pending=FollowTrail({0,0,0},landing,cfg,trail,index,sample,clear);
assert(pending.Waiting&&!pending.Step.Valid);
// Leader completes admission later. Follower must bind this published landing,
// including after the leader record is gone/has entered the instance.
trail.Landing={-4905.67f,-4232.75f,827.765625f};trail.LandingReady=true;
Point above{trail.Landing.X,trail.Landing.Y,trail.Landing.Z+10};trail.Points={above,trail.Landing};
// Old bug: stale zero goal retains the 8-yard floor margin during touchdown.
assert(!Corridor(above,trail.Landing,landing,cfg,sample,clear,true));
assert(trail.BindLanding(landing));index=1;
auto final=FollowTrail(above,landing,cfg,trail,index,sample,clear);
assert(final.Step.Valid&&final.Step.Landing&&Distance(final.Step.Next,landing)<.01f);
// In open air the next segment is ready before reaching the prior endpoint.
SharedTrail cruise;cruise.LandingReady=true;cruise.Landing={100,0,0};
cruise.Points={{25,0,60},{50,0,60}};
auto flat=[](float,float){return Surface{0,300,true};};
index=0;auto moving=FollowTrail({15,0,60},cruise.Landing,cfg,cruise,index,flat,clear,15);
assert(moving.Step.Valid&&moving.Step.Next.X==50&&index==1);
// No shortcut through a blocked corner, and no early escape-leg shortcut.
auto obstacle=[](Point const&a,Point const&b){return !(a.X<20&&b.X>30);};
index=0;auto corner=FollowTrail({15,0,60},cruise.Landing,cfg,cruise,index,flat,obstacle,15);
assert(corner.Step.Valid&&corner.Step.Next.X==25&&index==0);
cruise.UnderOverhang={true,false};index=0;
auto escape=FollowTrail({15,0,60},cruise.Landing,cfg,cruise,index,flat,clear,15);
assert(escape.Step.Valid&&index==0);
trail.Aborted=true;assert(!trail.BindLanding(landing));
assert(!FollowTrail(above,landing,cfg,trail,index,sample,clear).Step.Valid);
std::cout<<"Shared flight: late landing publication, terminal descent, early handoff, blocked/escape corners and abort PASS\n";
}
'''
with tempfile.TemporaryDirectory() as directory:
p=Path(directory);(p/'test.cpp').write_text(code)
subprocess.run(['c++','-std=c++17','-I'+str(root/'src/server/game/World'),str(p/'test.cpp'),'-o',str(p/'test')],check=True)
subprocess.run([str(p/'test')],check=True)
@@ -0,0 +1,61 @@
"""Join a leader trail from a later taxi stop instead of replaying its origin."""
from pathlib import Path
import subprocess,tempfile
root=Path(__file__).resolve().parents[2]
code=r'''
#include "ClientlessFlightPath.h"
#include <cassert>
#include <iostream>
using namespace ClientlessFlightPath;
int main(){Settings cfg;auto flat=[](float,float){return Surface{0,1000,true};};auto clear=[](Point const&,Point const&){return true;};
SharedTrail trail;trail.LandingReady=true;trail.Complete=true;trail.Landing={1200,0,0};
trail.Points={{0,0,20},{1025,0,60},{1050,0,60},{1100,0,60},{1200,0,0}};
SharedJoin join;std::size_t index=0;Point current{1000,0,0};bool connected=false;
for(unsigned i=0;i<100&&Distance(current,trail.Landing)>.1f;++i){
auto result=FollowTrail(current,trail.Landing,cfg,trail,index,flat,clear,0,&join);
assert(!result.Waiting&&result.Step.Valid);assert(result.Step.Next.X>=1000);
assert(Corridor(current,result.Step.Next,trail.Landing,cfg,flat,clear,true,result.Step.UnderOverhang));
current=result.Step.Next;if(!join.Active)connected=true;
}
assert(connected&&Distance(current,trail.Landing)<.1f&&index>=1);
// Original Winienwi geometry: the old first point is 2,088 yards behind
// the taxi stop and its direct segment intersects a raised surface.
Point taxi{-4170.55f,-4748.93f,126.786f};SharedTrail actual;
actual.LandingReady=true;actual.Landing={-4905.67f,-4232.75f,827.766f};
actual.Points={{-4863.52f,-6715.61f,24.22f},{-4850,-4750,540},actual.Landing};
auto hills=[](float x,float y){float ground=y< -6500?9.f:126.786f;
if(std::fabs(x+4175.523f)<2.5f&&std::fabs(y+4763.045f)<2.5f)ground=129.25f;
return Surface{ground,1000,true};};
assert(!Corridor(taxi,actual.Points[0],actual.Landing,cfg,hills,clear,true));
join={};index=0;auto connectedTaxi=FollowTrail(taxi,actual.Landing,cfg,actual,index,hills,clear,0,&join);
assert(connectedTaxi.Step.Valid&&join.Target==1&&connectedTaxi.Step.Next.Z>taxi.Z);
// Joining an airborne leg above a tower must still go around its platform.
SharedTrail tower;tower.LandingReady=true;tower.Landing={12,0,200};
tower.Points={{60,0,210},{12,0,210},tower.Landing};
auto platform=[](float x,float y){return Surface{std::hypot(x,y)<20?200.f:0.f,500,true,0.f};};
auto solid=[](Point const&a,Point const&b){for(unsigned 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;}return true;};
current={90,0,80};join={};index=0;
for(unsigned i=0;i<100&&Distance(current,tower.Landing)>.1f;++i){
auto step=FollowTrail(current,tower.Landing,cfg,tower,index,platform,solid,0,&join);
assert(step.Step.Valid&&!step.Waiting);current=step.Step.Next;
}
assert(Distance(current,tower.Landing)<.1f);
// No unvalidated shortcut through a tower or missing terrain.
join={};index=0;auto blocked=[](Point const&,Point const&){return false;};
assert(!FollowTrail({1000,0,0},trail.Landing,cfg,trail,index,flat,blocked,0,&join).Step.Valid);
auto missing=[](float,float){return Surface{};};join={};index=0;
assert(!FollowTrail({1000,0,0},trail.Landing,cfg,trail,index,missing,clear,0,&join).Step.Valid);
// A replacement publishes its verified destination immediately, even before
// any points are flown. Old readers can resolve to this generation.
auto old=std::make_shared<SharedTrail>();auto next=old->Replace({100,0,0});
assert(old->Aborted&&old->Replacement==next&&next->LandingReady&&next->Landing.X==100);
assert(next->Points.empty());next->Points={{25,0,20},{100,0,0}};next->Complete=true;
join={};index=0;auto step=FollowTrail({0,0,0},next->Landing,cfg,*next,index,flat,clear,0,&join);
assert(step.Step.Valid&&!step.Waiting);
std::cout<<"Shared route rejoin after taxi, bounded climb, clear corridor, blocked/missing terrain, replacement publication PASS\n";
}
'''
with tempfile.TemporaryDirectory(prefix="vcore-shared-rejoin-") as directory:
p=Path(directory);(p/"test.cpp").write_text(code)
subprocess.run(["c++","-std=c++17","-I"+str(root/"src/server/game/World"),str(p/"test.cpp"),"-o",str(p/"test")],check=True)
subprocess.run([str(p/"test")],check=True)
+7 -4
View File
@@ -5,6 +5,7 @@ root=Path(__file__).resolve().parents[2]
s=(root/'src/server/game/World/ClientlessActionExecutorMgr.cpp').read_text()
a=s.index('bool ClientlessActionExecutorMgr::AdoptRoute(')
code=r'''
#include "ClientlessTravelCost.h"
#include <algorithm>
#include <cassert>
#include <cmath>
@@ -20,9 +21,9 @@ struct Group{Guid leader{29};Guid GetLeaderGUID(){return leader;}}group;
struct Player{bool grouped=true;unsigned map=0;Guid GetGUID(){return {31};}Group*GetGroup(){return grouped?&group:nullptr;}unsigned GetMapId(){return map;}float GetExactDist(Position const&p){return std::hypot(p.x,p.y);}};
struct ClientlessAction{std::string Name;std::unordered_map<std::string,std::string>Parameters;};
struct ClientlessTravelRouteStep{std::string NodeKey,EdgeType;unsigned EdgeDatabaseId=0,MapId=0;float X=0,Y=0,Z=0;};
struct Graph{bool allowed=true;bool PlanAdaptiveRoute(Player*,std::string const&node,int,int,std::vector<ClientlessTravelRouteStep>&r,bool&complete,std::string&,std::string&){complete=true;r={{node,"start"}};return allowed;}}graph;auto sClientlessTravelGraphMgr=&graph;
struct Graph{ClientlessTravelCost::Profile observed;bool allowed=true;bool PlanAdaptiveRoute(Player*,std::string const&node,int,int,std::vector<ClientlessTravelRouteStep>&r,bool&complete,std::string&,std::string&,void const* =nullptr,void const* =nullptr,void const* =nullptr,ClientlessTravelCost::Profile const*costs=nullptr){if(costs)observed=*costs;complete=true;r={{node,"start"}};return allowed;}}graph;auto sClientlessTravelGraphMgr=&graph;
struct ClientlessActionExecutorMgr{
struct Record{std::vector<ClientlessTravelRouteStep>Route;size_t RouteStep=0;bool FinalLocalLegPending=false,RouteComplete=true,HardDestination=false,Active=false;
struct Record{ClientlessTravelCost::Profile JourneyCosts;std::string JourneyCostKey;bool HasJourneyCosts=false;std::vector<ClientlessTravelRouteStep>Route;size_t RouteStep=0;bool FinalLocalLegPending=false,RouteComplete=true,HardDestination=false,Active=false;
unsigned FinalMapId=0,FinalDatabaseId=0;float FinalX=0,FinalY=0,FinalZ=0,FinalO=0;std::string FinalLocationId,PlannedDestinationNode,AdaptiveReachedNode;};
std::unordered_map<unsigned,Record>_records;int starts=0;bool StartRouteStep(Player*,unsigned,Record&){++starts;return true;}
bool AdoptRoute(Player*,ClientlessAction const&,Record&);
@@ -31,14 +32,16 @@ struct ClientlessActionExecutorMgr{
int main(){
ClientlessActionExecutorMgr mgr;Player p;ClientlessAction a;a.Name="travel_to_location";a.Parameters={{"id","destination"},{"route_source","29"}};
auto& lead=mgr._records[29];lead.Route={{"start","start",0,0,0,0,0},{"taxi_end","taxi",18,0,30,0,0},{"flight_end","free_flight",0,0,80,0,0}};
lead.JourneyCosts=ClientlessTravelCost::Sample({},12345,0.2f);lead.JourneyCostKey="leader-trip";lead.HasJourneyCosts=true;
lead.RouteStep=1;lead.FinalLocationId="destination";lead.FinalDatabaseId=77;lead.FinalX=90;lead.FinalLocalLegPending=true;
ClientlessActionExecutorMgr::Record peer;
assert(mgr.AdoptRoute(&p,a,peer));assert(peer.Route.size()==3&&peer.Route[1].EdgeType=="taxi"&&peer.Route[1].EdgeDatabaseId==18&&peer.Route[2].EdgeType=="free_flight");
assert(peer.FinalLocationId=="destination"&&peer.FinalLocalLegPending&&peer.FinalX==90&&peer.FinalDatabaseId==77);
assert(peer.HasJourneyCosts&&peer.JourneyCosts.Seed==12345&&peer.JourneyCostKey=="leader-trip"&&peer.JourneyCosts.Factors==lead.JourneyCosts.Factors);
assert(peer.RouteStep==0&&peer.Active&&lead.RouteStep==1);peer.Route[1].NodeKey="changed";assert(lead.Route[1].NodeKey=="taxi_end");
// Late arrival joins the active route at its previous reached node.
lead.RouteStep=2;assert(mgr.AdoptRoute(&p,a,peer));assert(peer.Route.size()==2&&peer.Route[0].NodeKey=="taxi_end"&&peer.Route[0].EdgeType=="start"&&peer.Route[1].EdgeType=="free_flight");
p.map=1;assert(mgr.AdoptRoute(&p,a,peer));graph.allowed=false;assert(!mgr.AdoptRoute(&p,a,peer));graph.allowed=true;p.map=0;
p.map=1;assert(mgr.AdoptRoute(&p,a,peer));assert(graph.observed.Factors==lead.JourneyCosts.Factors&&graph.observed.Seed==12345);graph.allowed=false;assert(!mgr.AdoptRoute(&p,a,peer));graph.allowed=true;p.map=0;
// No cross-group route copying and no stale destination reuse.
group.leader={32};assert(!mgr.AdoptRoute(&p,a,peer));group.leader={29};p.grouped=false;assert(!mgr.AdoptRoute(&p,a,peer));p.grouped=true;
a.Parameters["id"]="different";assert(!mgr.AdoptRoute(&p,a,peer));a.Parameters["id"]="destination";
@@ -47,6 +50,6 @@ int main(){
'''
with tempfile.TemporaryDirectory() as d:
p=Path(d);(p/'route.cpp').write_text(code)
subprocess.run(['g++','-std=c++17',str(p/'route.cpp'),'-o',str(p/'route')],check=True)
subprocess.run(['g++','-std=c++17','-I'+str(root/'src/server/game/World'),str(p/'route.cpp'),'-o',str(p/'route')],check=True)
subprocess.run([str(p/'route')],check=True)
print('Production generic shared routes: same edges/taxi/free flight, independent execution state, final local goal, late catch-up, permissions and stale-goal rejection passed.')
+57 -5
View File
@@ -60,6 +60,7 @@ struct Position {
float GetExactDist(Position const*p)const{return std::sqrt((x-p->x)*(x-p->x)+(y-p->y)*(y-p->y)+(z-p->z)*(z-p->z));}
};
constexpr int MOVE_RUN=0,SPELL_AURA_MOUNTED=1;
constexpr int GAMEOBJECT_TYPE_TRANSPORT=11;
struct TransportDockState {uint32 Progress=0,Period=0,RemainingPause=0;bool Stopped=false;};
class Player; class GameObject;
class TransportBase {
@@ -73,7 +74,8 @@ world = r'''
};
class GameObject:public Position {
public:
TransportBase base;uint32 map=0,entry=1,spawn=1;TransportDockState state{0,30000,10000,true};
TransportBase base;uint32 map=0,entry=1,spawn=1;int type=15;TransportDockState state{0,30000,10000,true};
int GetGoType()const{return type;}
GameObject(){base.go=this;}
bool IsInWorld()const{return true;}uint32 GetMapId()const{return map;}
ObjectGuid GetGUID()const{return {spawn};}
@@ -87,9 +89,9 @@ class Player:public Position {
public:
TransportBase* transport=nullptr;uint32 map=0;GameObject* object=nullptr;Motion motion{this};bool pending=false;
struct {struct {Position pos;ObjectGuid guid;uint32 time=0;void Reset(){pos={};guid={};time=0;}}transport;}m_movementInfo;
int teleports=0;bool falling=false;
int teleports=0;bool falling=false,teleportAllowed=true;
bool IsFalling()const{return falling;}bool IsMounted(){return false;}void Dismount(){}void UpdateSpeed(int){}void RemoveAurasByType(int){}
bool TeleportTo(uint32 m,float a,float b,float c,float d){++teleports;map=m;Relocate(a,b,c,d);falling=false;return true;}
bool TeleportTo(uint32 m,float a,float b,float c,float d){if(!teleportAllowed)return false;++teleports;if(transport)transport->RemovePassenger(this);map=m;Relocate(a,b,c,d);falling=false;return true;}
bool IsInCombat(){return false;}bool IsAlive()const{return true;}bool IsFlying()const{return false;}bool IsInFlight()const{return false;}
bool IsInWorld()const{return true;}bool IsInPhase(GameObject*)const{return true;}
bool IsBeingTeleported()const{return pending;}TransportBase* GetTransport()const{return transport;}
@@ -135,7 +137,8 @@ methods = '\n'.join(function(source, marker) for marker in [
'bool ClientlessTransportMgr::HasConnection(', 'bool ClientlessTransportMgr::AtDock(',
'bool ClientlessTransportMgr::Sample(', 'bool ClientlessTransportMgr::Start(',
'void ClientlessTransportMgr::SetPhase(', 'void ClientlessTransportMgr::Cancel(',
'void ClientlessTransportMgr::RecoverPassenger(', 'void ClientlessTransportMgr::UpdateRide('
'void ClientlessTransportMgr::RecoverPassenger(', 'bool ClientlessTransportMgr::RecoverStuckElevator(',
'void ClientlessTransportMgr::UpdateRide('
])
stubs = r'''
void PublishPassengerState(Player*){}
@@ -295,7 +298,56 @@ int main(){
resumed.UpdateRide(&fallen,ride,100);
assert(ride.Status.Recovering&&fallen.teleports==1&&fallen.x==0);
}
std::cout<<"Transport dock, frame, recorder, cross-map copy and ride regressions passed\n";
// Elevators need 500ms to begin boarding; exiting needs only the stopped dock.
for(bool reverse:{false,true}){
ClientlessTransportMgr lift;lift._stops["A"]=stop("A",5,0);lift._stops["B"]=stop("B",100,104);
Player rider;GameObject platform;platform.type=11;rider.object=&platform;
auto from=reverse?"B":"A",to=reverse?"A":"B";
platform.x=reverse?100:5;rider.x=reverse?104:0;platform.state.RemainingPause=499;platform.state.Stopped=false;
assert(lift.Start(&rider,from,to,error));auto& ride=lift._rides.at(26);
run(lift,rider,ride);assert(ride.Status.Phase==Phase::Waiting&&!rider.transport);
platform.state.Stopped=true;run(lift,rider,ride);assert(ride.Status.Phase==Phase::Waiting&&!rider.transport);
platform.state.RemainingPause=500;run(lift,rider,ride);assert(ride.Status.Phase==Phase::Riding&&rider.transport);
platform.x=50;rider.x=51;run(lift,rider,ride);assert(ride.Status.Phase==Phase::Riding&&rider.transport);
platform.x=reverse?5:100;rider.x=platform.x+1;platform.state.Stopped=false;
run(lift,rider,ride);assert(ride.Status.Phase==Phase::Riding&&rider.transport);
platform.state.Stopped=true;platform.state.RemainingPause=1;run(lift,rider,ride);assert(ride.Status.Phase==Phase::Arrived&&!rider.transport&&rider.x==(reverse?0:104));
// Actual passenger time, not waiting on land, gates the five-minute rescue.
rider.x=reverse?104:0;platform.x=reverse?100:5;platform.state.RemainingPause=500;assert(lift.Start(&rider,from,to,error));run(lift,rider,lift._rides.at(26));
auto& stuck=lift._rides.at(26);platform.x=50;rider.x=51;platform.state.Stopped=false;
stuck.ElevatorPassengerMs=0;stuck.Elapsed=0;lift.UpdateRide(&rider,stuck,299999);
assert(rider.teleports==0&&rider.transport&&stuck.ElevatorDestination==to&&!stuck.Abort);
lift.UpdateRide(&rider,stuck,1);assert(rider.teleports==1&&!rider.transport&&rider.x==(reverse?0:104));
lift.UpdateRide(&rider,stuck,1);assert(stuck.Status.Phase==Phase::Arrived);
lift.UpdateRide(&rider,stuck,300000);assert(rider.teleports==1);
// A cancelled/retried exit cannot change the original recovery destination.
stuck.Status.Phase=Phase::Riding;stuck.ElevatorFallback=false;stuck.Abort=true;
stuck.To=stuck.From;stuck.RetryDelay=3000;platform.base.AddPassenger(&rider);
lift.UpdateRide(&rider,stuck,1);assert(rider.teleports==2&&rider.x==(reverse?0:104));
}
// A ship, a missing/foreign attachment or a forbidden endpoint cannot use elevator rescue.
ClientlessTransportMgr rescue;rescue._stops["A"]=stop("A",5,0);rescue._stops["B"]=stop("B",100,104);
Player passenger;GameObject vehicle;passenger.object=&vehicle;vehicle.base.AddPassenger(&passenger);
ClientlessTransportMgr::Ride stranded;stranded.From=rescue._stops["A"];stranded.To=rescue._stops["B"];
assert(!rescue.RecoverStuckElevator(&passenger,&vehicle,stranded,300000)&&!passenger.teleports);
vehicle.type=11;vehicle.base.RemovePassenger(&passenger);
assert(!rescue.RecoverStuckElevator(&passenger,&vehicle,stranded,300000)&&!stranded.ElevatorPassengerMs);
vehicle.base.AddPassenger(&passenger);areas.denied="B";
assert(!rescue.RecoverStuckElevator(&passenger,&vehicle,stranded,300000)&&!passenger.teleports);areas.denied.clear();
passenger.teleportAllowed=false;assert(!rescue.RecoverStuckElevator(&passenger,&vehicle,stranded,1)&&!stranded.ElevatorFallback);
passenger.teleportAllowed=true;assert(rescue.RecoverStuckElevator(&passenger,&vehicle,stranded,1));
// Departure during an elevator transition retains the live journey, whereas
// walking the final land segment no longer depends on the departed platform.
ClientlessTransportMgr quick;quick._stops["A"]=stop("A",5,0);quick._stops["B"]=stop("B",100,104);
Player commuter;GameObject cabin;cabin.type=11;cabin.state.Stopped=false;commuter.object=&cabin;cabin.base.AddPassenger(&commuter);
ClientlessTransportMgr::Ride interrupted;interrupted.From=quick._stops["A"];interrupted.To=quick._stops["B"];
interrupted.Status.Phase=Phase::Boarding;interrupted.Point=2;
quick.UpdateRide(&commuter,interrupted,100);
assert(interrupted.Status.Phase==Phase::Riding&&!interrupted.Abort&&commuter.transport);
cabin.base.RemovePassenger(&commuter);commuter.x=103;
interrupted.Status.Phase=Phase::Leaving;interrupted.Point=1;interrupted.Moving=false;
run(quick,commuter,interrupted);assert(interrupted.Status.Phase==Phase::Arrived&&commuter.x==104);
std::cout<<"Transport dock, elevator pause/five-minute rescue, frame, recorder, cross-map copy and ride regressions passed\n";
}
'''
with tempfile.TemporaryDirectory(prefix='vcore-transport-test-') as directory:
@@ -0,0 +1,50 @@
"""Production native transport arclength, directed wraparound and static animation distance."""
from pathlib import Path
import subprocess,tempfile
root=Path(__file__).resolve().parents[2]
s=(root/'src/server/game/World/ClientlessTransportMgr.cpp').read_text()
a=s.index('float ClientlessTransportMgr::ConnectionDistance(');b=s.index('bool ClientlessTransportMgr::ObserveJourney(',a)
code=r'''
#include <cassert>
#include <cmath>
#include <limits>
#include <map>
#include <memory>
#include <string>
#include <unordered_map>
#include <vector>
using uint32=unsigned;
struct Position{float x=0,y=0,z=0;Position(){}Position(float a,float b,float c):x(a),y(b),z(c){}float GetPositionX()const{return x;}float GetPositionY()const{return y;}float GetPositionZ()const{return z;}float GetExactDist(Position const&p)const{return std::sqrt((x-p.x)*(x-p.x)+(y-p.y)*(y-p.y)+(z-p.z)*(z-p.z));}};
namespace G3D{struct Matrix{};struct Vector3{float x=0,y=0,z=0;Vector3(){}Vector3(float a,float b,float c):x(a),y(b),z(c){}Vector3 operator*(Matrix)const{return *this;}};struct Quat{Quat(float,float,float,float){}Matrix toRotationMatrix(){return {};}};}
struct QuaternionData{float x=0,y=0,z=0,w=1;};
struct Spline{double Length;float X,Span;double length()const{return Length;}void evaluate_percent(float t,G3D::Vector3&p)const{p={X+t*Span,0,0};}};
struct Segment{double DistanceFromLegStartAtEnd;};
struct Leg{unsigned MapId;std::shared_ptr<Spline>Spline;std::vector<Segment>Segments;};
struct Native{std::vector<Leg>PathLegs;};
struct AnimPoint{struct{float X,Y,Z;}Pos;};struct Animation{std::map<unsigned,AnimPoint const*>Path;};
struct TransportManager{Native native;Animation animation;bool useNative=true;Native*GetTransportTemplate(unsigned){return useNative?&native:nullptr;}Animation*GetTransportAnimInfo(unsigned){return &animation;}}tm;auto sTransportMgr=&tm;
struct Spawn{Position spawnPoint{100,0,0};}spawn;
struct Addon{QuaternionData ParentRotation;}addon;
struct Objects{Spawn*GetGameObjectData(unsigned){return &spawn;}Addon*GetGameObjectAddon(unsigned){return &addon;}}objects;auto sObjectMgr=&objects;
struct ClientlessTransportStop{unsigned Entry=1,Spawn=5,Map=0;Position Dock;};
struct ClientlessTransportMgr{std::unordered_map<std::string,ClientlessTransportStop>stops;auto GetStop(std::string const&n){return stops.count(n)?&stops[n]:nullptr;}float ConnectionDistance(std::string const&,std::string const&);};
'''+s[a:b]+r'''
int main(){
ClientlessTransportMgr m;m.stops["from"]={1,5,0,{10,0,0}};m.stops["to"]={1,5,1,{1000005,0,0}};
tm.native.PathLegs={{0,std::make_shared<Spline>(Spline{100,0,10}),{{100}}},{1,std::make_shared<Spline>(Spline{300,1000000,20}),{{75},{300}}}};
assert(m.ConnectionDistance("from","to")==75);assert(m.ConnectionDistance("to","from")==325);
// Geometric jumps between maps contribute zero, native curved-leg length is retained.
assert(m.ConnectionDistance("missing","to")==0);
tm.useNative=false;AnimPoint p0{{0,0,0}},p1{{3,0,0}},p2{{10,0,0}};
tm.animation.Path={{0,&p0},{1,&p1},{2,&p2},{3,&p0}};
m.stops["from"].Map=m.stops["to"].Map=0;m.stops["from"].Dock={103,0,0};m.stops["to"].Dock={110,0,0};
assert(m.ConnectionDistance("from","to")==7);assert(m.ConnectionDistance("to","from")==13);
}
'''
# Avoid a type/member identifier collision in this small native-shaped fixture.
code=code.replace('struct Spline{','struct NativeSpline{').replace('std::shared_ptr<Spline>Spline','std::shared_ptr<NativeSpline>Spline').replace('std::make_shared<Spline>(Spline{','std::make_shared<NativeSpline>(NativeSpline{')
with tempfile.TemporaryDirectory(prefix='vcore-transport-costs-') as d:
p=Path(d);(p/'test.cpp').write_text(code)
subprocess.run(['c++','-std=c++17',str(p/'test.cpp'),'-o',str(p/'test')],check=True)
subprocess.run([str(p/'test')],check=True)
print('Production native transport distances: spline arclength, cross-map discontinuities, directed cycle and static animation stops passed.')
@@ -9,6 +9,7 @@ code=r'''
using uint32=unsigned;using uint64=unsigned long long;
#define TC_LOG_WARN(...) ((void)0)
constexpr int SMSG_MOVE_UPDATE=1;
constexpr int GAMEOBJECT_TYPE_TRANSPORT=11;
struct Position{float x=0,y=0,z=0,o=0;float GetPositionX()const{return x;}float GetPositionY()const{return y;}float GetPositionZ()const{return z;}float GetOrientation()const{return o;}void Relocate(Position const&p){*this=p;}};
struct Guid{unsigned GetCounter(){return 120;}};
struct WorldPacket{explicit WorldPacket(int){}};
@@ -29,7 +30,7 @@ struct Player:Position{bool alive=true,combat=false,teleport=false;int*world=&ma
};
void Transport::UpdatePassengerPosition(int*,Player*p,float x,float y,float z,float o,bool){++relocations;p->x=x;p->y=y;p->z=z;p->o=o;}
struct TransportDockState{bool Stopped=true;unsigned RemainingPause=10000;};
struct GameObject{TransportDockState dock;bool valid=true;Transport*ToTransportBase(){return valid?&transport:nullptr;}int*GetMap(){return &map;}bool GetTransportDockState(TransportDockState&d){d=dock;return true;}unsigned GetEntry(){return 176081;}}object;
struct GameObject{TransportDockState dock;bool valid=true;int type=15;int GetGoType(){return type;}Transport*ToTransportBase(){return valid?&transport:nullptr;}int*GetMap(){return &map;}bool GetTransportDockState(TransportDockState&d){d=dock;return true;}unsigned GetEntry(){return 176081;}}object;
'''+s[a:b]+r'''
int main(){
Player p;Position anchor{2,-3,-10,0};
@@ -45,6 +46,11 @@ int main(){
p.attached=nullptr;assert(!ReturnToRecordedStand(&p,&object,anchor,5000));p.attached=&transport;
anchor.x=101;assert(!ReturnToRecordedStand(&p,&object,anchor,5000));anchor.x=NAN;assert(!ReturnToRecordedStand(&p,&object,anchor,5000));
assert(transport.relocations==1&&p.updates==1); // rejected operations never move or detach
// Elevators need a real stop, but no time reserve even for stand recovery.
anchor.x=2;object.type=11;object.dock.RemainingPause=1;
assert(ReturnToRecordedStand(&p,&object,anchor,5000));
object.dock.Stopped=false;assert(!ReturnToRecordedStand(&p,&object,anchor,5000));
assert(transport.relocations==2&&p.updates==2);
}
'''
with tempfile.TemporaryDirectory()as d:
@@ -0,0 +1,50 @@
"""Exercise production executor journey initialization, retry stability and leader inheritance."""
from pathlib import Path
import subprocess,tempfile
root=Path(__file__).resolve().parents[2]
s=(root/'src/server/game/World/ClientlessActionExecutorMgr.cpp').read_text()
a=s.index('bool ClientlessActionExecutorMgr::StartAction(')
b=s.index(' record.StopAtNode = false;',a)
method=s[a:b]+' return true;\n}\n'
code=r'''
#include "ClientlessTravelCost.h"
#include <cassert>
#include <cstdlib>
#include <unordered_map>
using uint32=unsigned;
#define TC_LOG_INFO(...) ((void)0)
struct Guid{unsigned id;unsigned GetCounter()const{return id;}};
struct Group{unsigned leader=29;Guid GetLeaderGUID(){return {leader};}}group;
struct Player{unsigned id=29;Guid GetGUID(){return {id};}Group*GetGroup(){return &group;}};
struct ClientlessAction{std::string Name="travel_to_node";std::unordered_map<std::string,std::string>Parameters{{"node","destination"}};};
enum class ClientlessActionExecutionState{Idle,Completed,Failed,SelectingTarget};
struct Graph{unsigned draws=0;auto NewJourneyCosts(){return ClientlessTravelCost::Sample({},++draws,.2f);}}graph;auto sClientlessTravelGraphMgr=&graph;
struct ClientlessActionExecutorMgr{
struct Record{ClientlessTravelCost::Profile JourneyCosts;std::string JourneyCostKey;bool HasJourneyCosts=false;struct{ClientlessActionExecutionState State=ClientlessActionExecutionState::Idle;}Status;};
std::unordered_map<unsigned,Record>_records;
bool StartAction(Player*,ClientlessAction const&,Record&);
};
'''+method+r'''
int main(){
ClientlessActionExecutorMgr m;Player leader;ClientlessAction action;auto& r=m._records[29];
m.StartAction(&leader,action,r);auto original=r.JourneyCosts;assert(graph.draws==1);
r.Status.State=ClientlessActionExecutionState::Failed;m.StartAction(&leader,action,r);
assert(graph.draws==1&&r.JourneyCosts.Factors==original.Factors);
r.Status.State=ClientlessActionExecutionState::SelectingTarget;m.StartAction(&leader,action,r);assert(graph.draws==1);
action.Parameters["node"]="another-destination";m.StartAction(&leader,action,r);assert(graph.draws==2);
r.Status.State=ClientlessActionExecutionState::Completed;m.StartAction(&leader,action,r);assert(graph.draws==3);
Player follower;follower.id=31;action.Parameters["route_source"]="29";auto& peer=m._records[31];
// Preferences are already inherited before a shared route is available.
m.StartAction(&follower,action,peer);assert(graph.draws==3&&peer.JourneyCosts.Factors==r.JourneyCosts.Factors);
action.Parameters.erase("route_source");peer.Status.State=ClientlessActionExecutionState::SelectingTarget;
m.StartAction(&follower,action,peer);assert(graph.draws==3&&peer.JourneyCosts.Seed==r.JourneyCosts.Seed);
// A forged/stale source from another group is not inherited.
group.leader=99;action.Parameters["route_source"]="29";peer.Status.State=ClientlessActionExecutionState::Completed;
m.StartAction(&follower,action,peer);assert(graph.draws==4&&peer.JourneyCosts.Seed!=r.JourneyCosts.Seed);
}
'''
with tempfile.TemporaryDirectory(prefix='vcore-journey-costs-') as d:
p=Path(d);(p/'test.cpp').write_text(code)
subprocess.run(['c++','-std=c++17','-I'+str(root/'src/server/game/World'),str(p/'test.cpp'),'-o',str(p/'test')],check=True)
subprocess.run([str(p/'test')],check=True)
print('Production journey initialization: new trip/new draw, stable retries, delayed leader route and group-safe modal inheritance passed.')
+64
View File
@@ -0,0 +1,64 @@
"""Measured production edge lengths and stable per-journey modal preferences."""
from pathlib import Path
import subprocess, tempfile
root=Path(__file__).resolve().parents[2]
s=(root/'src/server/game/World/ClientlessTravelGraphMgr.cpp').read_text()
a=s.index('void ClientlessTravelGraphMgr::RebuildEdgeDistances(')
b=s.index('\nstd::string ClientlessTravelGraphMgr::FindNearestStartNode(',a)
code=r'''
#include "ClientlessTravelCost.h"
#include <cassert>
#include <cstdlib>
#include <limits>
#include <unordered_map>
#include <vector>
using uint32=unsigned;
#define TC_LOG_DEBUG(...) ((void)0)
struct Node{unsigned MapId=0;float X=0,Y=0,Z=0;};
struct Point:Node{float O=0;};
struct Edge{unsigned DatabaseId=0;std::string FromNode,ToNode,Type;float Cost=99999;bool Reverse=false;};
struct NativePoint{unsigned ContinentID;struct{float X,Y,Z;}Loc;};
using TaxiPathNodeList=std::vector<NativePoint const*>;
std::vector<TaxiPathNodeList>sTaxiPathNodesByPath(2);
struct Objects{void GetTaxiPath(unsigned,unsigned,unsigned&p,unsigned&fare){p=1;fare=98765;}}objects;auto sObjectMgr=&objects;
struct Transports{static bool IsTransportEdge(std::string const&t){return t=="ship"||t=="tram";}float ConnectionDistance(std::string const&,std::string const&){return 777;}}transports;
using ClientlessTransportMgr=Transports;auto sClientlessTransportMgr=&transports;
struct ClientlessTravelGraphMgr{
std::unordered_map<std::string,Node>_nodes;std::unordered_map<std::string,std::vector<Edge>>_edges;
std::unordered_map<unsigned,std::vector<Point>>points;
auto GetEdgePoints(unsigned id){return points.count(id)?&points[id]:nullptr;}
void RebuildEdgeDistances();
};
'''+s[a:b]+r'''
int main(){
using namespace ClientlessTravelCost;
Profile neutral;
auto first=Sample(neutral,1234,.2f),same=Sample(neutral,1234,.2f),other=Sample(neutral,9876,.2f);
assert(first.Factors==same.Factors&&first.Factors!=other.Factors);
for(float factor:first.Factors)assert(factor>=.8f&&factor<=1.2f);
assert(Sample(neutral,1234,0).Factors==neutral.Factors);
bool ground=false,taxi=false;
for(unsigned seed=1;seed<=200;++seed){auto p=Sample(neutral,seed,.2f);if(p.Cost(Ground,1000)<p.Cost(Taxi,1050))ground=true;else taxi=true;}
assert(ground&&taxi); // Repeated journeys can choose different near-equal modes.
neutral.Factors[Flight]=2;assert(neutral.Cost(Flight,100)==200);
assert(neutral.Cost(Portal,1000000)==1); // Cross-map coordinates never act as travelled distance.
ClientlessTravelGraphMgr g;g._nodes["A"]={0,0,0,0};g._nodes["B"]={0,10,0,0};
g._nodes["taxi_1"]={0,0,0,0};g._nodes["taxi_2"]={1,100000,0,0};
g._edges["A"]={{1,"A","B","ground",99999,false},{2,"A","taxi_2","portal_use",99999,false},
{3,"A","B","ship",100,false},{4,"A","taxi_2","ground",1,false}};
g._edges["B"]={{1,"B","A","ground",1,true}};
g.points[1]={{{0,0,0,0}},{{0,0,10,0}},{{0,10,10,0}},{{0,10,0,0}}};
g._edges["taxi_1"]={{5,"taxi_1","taxi_2","taxi",1,false}};
NativePoint a{0,{0,0,0}},b{0,{3,4,0}},c{0,{3,4,12}},d{1,{100000,0,0}},e{1,{100003,4,0}};
sTaxiPathNodesByPath[1]={&a,&b,&c,&d,&e};g.RebuildEdgeDistances();
assert(g._edges["A"][0].Cost==30&&g._edges["B"][0].Cost==30);
assert(g._edges["A"][1].Cost==1&&g._edges["A"][2].Cost==777);
assert(!std::isfinite(g._edges["A"][3].Cost));
assert(g._edges["taxi_1"][0].Cost==22); // Native geometry, not ticket price; no cross-map jump.
}
'''
with tempfile.TemporaryDirectory(prefix='vcore-travel-costs-') as d:
p=Path(d);(p/'test.cpp').write_text(code)
subprocess.run(['c++','-std=c++17','-I'+str(root/'src/server/game/World'),str(p/'test.cpp'),'-o',str(p/'test')],check=True)
subprocess.run([str(p/'test')],check=True)
print('Production measured roads/native taxi paths/portal transitions/transport distance and reproducible per-mode journey variation passed.')
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""Run production VMAP tile lifecycle methods against temporary files and a model cache.
--baseline <git-ref> reproduces both crash prerequisites using the specified revision.
"""
from pathlib import Path
import subprocess
import sys
import tempfile
ROOT = Path(__file__).resolve().parents[2]
BASELINE = sys.argv[sys.argv.index('--baseline') + 1] if '--baseline' in sys.argv else None
def read(relative):
if BASELINE:
return subprocess.check_output(['git', 'show', BASELINE + ':' + relative], cwd=ROOT, text=True)
return (ROOT / relative).read_text()
def function(source, marker):
start = source.index(marker)
opening = source.index('{', start)
depth, end = 1, opening + 1
while depth:
depth += (source[end] == '{') - (source[end] == '}')
end += 1
return source[start:end]
source = read('src/common/Collision/Maps/MapTree.cpp')
header = read('src/common/Collision/Maps/MapTree.h')
header = '\n'.join(line for line in header.splitlines() if not line.startswith('#'))
header = header.replace('private:', 'public:').replace('TC_COMMON_API ', '')
methods = '\n'.join(function(source, marker) for marker in [
'std::string StaticMapTree::getTileFileName(',
'StaticMapTree::StaticMapTree(', 'StaticMapTree::~StaticMapTree(',
'StaticMapTree::TileFileOpenResult StaticMapTree::OpenMapTileFile(',
'LoadResult StaticMapTree::CanLoadMap(', 'void StaticMapTree::UnloadMap(',
'LoadResult StaticMapTree::LoadMapTile(', 'void StaticMapTree::UnloadMapTile('
])
preamble = r'''
#include <algorithm>
#include <cassert>
#include <cstdio>
#include <cstdint>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <map>
#include <sstream>
#include <string>
#include <unordered_map>
#include <vector>
using uint8=uint8_t;using uint32=uint32_t;using int32=int32_t;
#define TC_LOG_DEBUG(...) ((void)0)
#define TC_LOG_ERROR(...) ((void)0)
#define VMAP_ERROR_LOG(...) ((void)0)
#define TC_METRIC_EVENT(...) ((void)0)
namespace G3D{struct Vector3{};struct Ray{};float finf(){return 1e30f;}}
namespace VMAP{
constexpr char VMAP_MAGIC[]="VMAPtest";
bool readChunk(FILE*f,char*buffer,char const*magic,int length){return int(fread(buffer,1,length,f))==length&&!std::memcmp(buffer,magic,length);}
enum class LoadResult:uint8{Success,FileNotFound,VersionMismatch,ReadFromFileFailed,DisabledInConfig};
struct BIH{};class GroupModel{};
class WorldModel{public:std::string name;std::string const&GetName()const{return name;}};
struct ModelSpawn{
uint32 ID=0,flags=0;std::string name;
static bool readFromFile(FILE*f,ModelSpawn&s){uint32 n;if(fread(&s.ID,4,1,f)!=1||fread(&n,4,1,f)!=1||n>10000)return false;s.name.resize(n);return fread(s.name.data(),1,n,f)==n;}
};
class ModelInstance{
WorldModel*model=nullptr;
public:ModelInstance()=default;ModelInstance(ModelSpawn const&,WorldModel*m):model(m){};
WorldModel*getWorldModel(){return model;}void setUnloaded(){model=nullptr;}
};
class VMapManager2{
public:
struct Cached{WorldModel*model;int refs;};std::map<std::string,Cached>cache;
int failures=0,acquires=0,releases=0,badReleases=0;
~VMapManager2(){for(auto&pair:cache)delete pair.second.model;}
int32 getParentMapId(uint32 map){return map==1?0:-1;}
static std::string getMapFileName(uint32 map){std::ostringstream out;out<<std::setw(3)<<std::setfill('0')<<map<<".vmtree";return out.str();}
WorldModel*acquireModelInstance(std::string const&,std::string const&name,uint32=0){++acquires;if(failures){--failures;return nullptr;}auto it=cache.find(name);if(it==cache.end())it=cache.emplace(name,Cached{new WorldModel{name},0}).first;++it->second.refs;return it->second.model;}
void releaseModelInstance(std::string const&name){++releases;auto it=cache.find(name);if(it==cache.end()){++badReleases;return;}if(!--it->second.refs){delete it->second.model;cache.erase(it);}}
int refs(std::string const&name){auto it=cache.find(name);return it==cache.end()?0:it->second.refs;}
};
}
'''
tests = r'''
namespace VMAP{
void init(StaticMapTree&t){t.iNTreeValues=2;t.iTreeValues=new ModelInstance[2];t.iSpawnIndices={{7,0},{8,1}};}
void writeTile(std::string const&base,uint32 map,uint32 x,uint32 y,std::vector<std::pair<uint32,std::string>> const&models,uint32 declared=0){
auto path=base+StaticMapTree::getTileFileName(map,x,y);FILE*f=fopen(path.c_str(),"wb");assert(f);fwrite(VMAP_MAGIC,1,8,f);uint32 count=declared?declared:uint32(models.size());fwrite(&count,4,1,f);
for(auto const&model:models){uint32 id=model.first,n=uint32(model.second.size());fwrite(&id,4,1,f);fwrite(&n,4,1,f);fwrite(model.second.data(),1,n,f);}fclose(f);
}
size_t fds(){size_t count=0;for(auto const&entry:std::filesystem::directory_iterator("/proc/self/fd")){(void)entry;++count;}return count;}
void probe(std::string const&base){
{std::ofstream f(base+"000.vmtree",std::ios::binary);f.write(VMAP_MAGIC,8);}writeTile(base,0,2,3,{{7,"crystal"}});
VMapManager2 vm;auto before=fds();for(int i=0;i<128;++i)assert(StaticMapTree::CanLoadMap(base,0,2,3,&vm)==LoadResult::Success);assert(fds()==before);
// A real parent-map fallback must validate the already opened parent tile.
{std::ofstream f(base+"001.vmtree",std::ios::binary);f.write(VMAP_MAGIC,8);}
assert(StaticMapTree::CanLoadMap(base,1,2,3,&vm)==LoadResult::Success);assert(fds()==before);
{std::ofstream f(base+StaticMapTree::getTileFileName(0,2,3),std::ios::binary);f<<"badmagic";}
assert(StaticMapTree::CanLoadMap(base,0,2,3,&vm)==LoadResult::VersionMismatch);assert(fds()==before);
}
void lifetime(std::string const&base){
VMapManager2 vm;StaticMapTree tree(0,base);init(tree);writeTile(base,0,4,5,{{7,"crystal"}});
// Transient failure while a tile stream consumes the last file descriptor.
vm.failures=1;tree.LoadMapTile(4,5,&vm);
// Later the stream closes, letting a dynamic GameObject load this same model.
auto dynamic=vm.acquireModelInstance(base,"crystal");assert(dynamic&&vm.refs("crystal")==1);
tree.UnloadMapTile(4,5,&vm);assert(vm.refs("crystal")==1&&vm.releases==0);vm.releaseModelInstance("crystal");
}
void shared(std::string const&base){
VMapManager2 vm;StaticMapTree tree(0,base);init(tree);auto dynamic=vm.acquireModelInstance(base,"shared");assert(dynamic);
writeTile(base,0,2,3,{{7,"shared"}});writeTile(base,0,4,5,{{7,"shared"}});
tree.LoadMapTile(2,3,&vm);tree.LoadMapTile(4,5,&vm);assert(vm.refs("shared")==3);
tree.LoadMapTile(2,3,&vm);assert(vm.refs("shared")==3); // repeated checks do not acquire twice
std::filesystem::remove(base+StaticMapTree::getTileFileName(0,2,3)); // unloading cannot depend on reopening data
tree.UnloadMapTile(2,3,&vm);assert(vm.refs("shared")==2&&tree.iTreeValues[0].getWorldModel());
tree.UnloadMap(&vm);assert(vm.refs("shared")==1&&!tree.iTreeValues[0].getWorldModel()&&!tree.numLoadedTiles());
tree.UnloadMap(&vm);assert(vm.refs("shared")==1&&vm.badReleases==0);vm.releaseModelInstance("shared");
// Truncated tile: release exactly the successfully read prefix, even if the file is removed.
writeTile(base,0,6,7,{{7,"prefix"}},2);assert(tree.LoadMapTile(6,7,&vm)==LoadResult::ReadFromFileFailed);
assert(vm.refs("prefix")==1);std::filesystem::remove(base+StaticMapTree::getTileFileName(0,6,7));tree.UnloadMap(&vm);assert(!vm.refs("prefix")&&!vm.badReleases);
// Missing data is tracked as an empty tile without acquiring/releasing any models.
assert(tree.LoadMapTile(30,31,&vm)==LoadResult::FileNotFound);tree.UnloadMap(&vm);assert(vm.badReleases==0);
// Overlapping parent spawns absent from the child's index are skipped without leaking references.
StaticMapTree child(1,base);init(child);writeTile(base,0,8,9,{{99,"parent-only"},{7,"indexed"}});
assert(child.LoadMapTile(8,9,&vm)==LoadResult::Success);assert(!vm.refs("parent-only")&&vm.refs("indexed")==1);child.UnloadMap(&vm);assert(!vm.refs("indexed")&&!vm.badReleases);
}
}
int main(int argc,char**argv){assert(argc==3);std::string base=std::string(argv[2])+"/";std::string test=argv[1];if(test=="fd")VMAP::probe(base);else if(test=="lifetime")VMAP::lifetime(base);else VMAP::shared(base);}
'''
with tempfile.TemporaryDirectory(prefix='vcore-vmap-lifetime-') as folder:
folder = Path(folder)
cpp = folder / 'test.cpp'
cpp.write_text(preamble + header + '\nnamespace VMAP{\n' + methods + '\n}\n' + tests)
exe = folder / 'test'
subprocess.run(['g++', '-std=c++17', '-O0', '-g', '-Wall', '-Wextra', str(cpp), '-o', str(exe)], check=True)
for case in (['fd', 'lifetime'] if BASELINE else ['fd', 'lifetime', 'shared']):
data = folder / case
data.mkdir()
result = subprocess.run([str(exe), case, str(data)], capture_output=True, text=True)
if BASELINE:
assert result.returncode != 0, (case, 'baseline unexpectedly passed')
print('Baseline reproduced:', case, 'exit', result.returncode, result.stderr.strip())
else:
assert result.returncode == 0, (case, result.stdout, result.stderr)
print('Passed:', case)
@@ -0,0 +1,39 @@
"""Run production WG client permission synchronization and battle refresh."""
from pathlib import Path
import subprocess,tempfile
root=Path(__file__).resolve().parents[2]
s=(root/"src/server/game/Battlefield/BattlefieldMgr.cpp").read_text()
a=s.index("void BattlefieldMgr::UpdateWintergraspFlight(Player*")
b=s.index("\nBattlefield* BattlefieldMgr::GetBattlefieldToZoneId",a)
code=r'''
#include <cassert>
#include <vector>
#include <set>
using uint32=unsigned;
constexpr unsigned SPELL_AURA_MOUNT_RESTRICTIONS=336;
enum class AreaMountFlags:unsigned{AllowFlyingMounts=2};
struct AuraEffect {unsigned id,misc;unsigned GetId()const{return id;}int GetMiscValue()const{return misc;}};
struct Player {unsigned zone=4197,area=4197,map=571;int casts=0,updates=0;std::set<unsigned> auras;std::vector<AuraEffect const*> effects;
unsigned GetMapId()const{return map;}unsigned GetAreaId()const{return area;}unsigned GetZoneId()const{return zone;}
auto const& GetAuraEffectsByType(unsigned)const{return effects;}bool HasAura(unsigned id)const{return auras.count(id);}
void AddAura(unsigned id,Player*){auras.insert(id);++casts;}void RemoveAurasDueToSpell(unsigned id){auras.erase(id);}void UpdateMountCapability(){++updates;}};
struct Reference {Player* player;Player* GetSource()const{return player;}};
struct Map {std::vector<Reference> players;auto const& GetPlayers()const{return players;}};
struct BattlefieldMgr {bool permitted=true;bool IsWintergraspFlightAllowed(unsigned map,unsigned area)const{return permitted&&map==571&&(area==4197||area==4538);}void UpdateWintergraspFlight(Player*)const;void UpdateWintergraspFlight(Map*)const;};
'''+s[a:b]+r'''
int main(){BattlefieldMgr m;Player p,out;out.zone=210;out.area=210;Map map{{{&p},{&out}}};
m.UpdateWintergraspFlight(&map);assert(p.HasAura(91262)&&p.casts==1&&p.updates==1&&!out.HasAura(91262));
AuraEffect own{91262,15};p.effects={&own};m.UpdateWintergraspFlight(&p);assert(p.casts==1&&p.HasAura(91262));
m.permitted=false;m.UpdateWintergraspFlight(&map);assert(!p.HasAura(91262)&&p.updates==2);
m.permitted=true;m.UpdateWintergraspFlight(&map);assert(p.HasAura(91262)&&p.casts==2);
p.area=4538;m.UpdateWintergraspFlight(&p);assert(p.HasAura(91262));
p.area=210;m.UpdateWintergraspFlight(&p);assert(!p.HasAura(91262));
p.area=4197;AuraEffect restriction{91124,13};p.effects={&restriction};m.UpdateWintergraspFlight(&p);assert(!p.HasAura(91262));
AuraEffect permission{86973,15};p.effects={&permission};m.UpdateWintergraspFlight(&p);assert(p.HasAura(91262));
p.effects={&own,&restriction};m.UpdateWintergraspFlight(&p);assert(!p.HasAura(91262));}
'''
with tempfile.TemporaryDirectory(prefix="vcore-wg-client-permission-") as d:
p=Path(d);(p/"test.cpp").write_text(code)
subprocess.run(["c++","-std=c++17",str(p/"test.cpp"),"-o",str(p/"test")],check=True)
subprocess.run([str(p/"test")],check=True)
print("WG permission: entry/subarea/idempotence/battle start/end/exit/aura precedence PASS")
@@ -0,0 +1,25 @@
"""Exercise shared Wintergrasp mount permission with actual manager method."""
from pathlib import Path
import subprocess,tempfile
root=Path(__file__).resolve().parents[2]
s=(root/"src/server/game/Battlefield/BattlefieldMgr.cpp").read_text()
a=s.index("bool BattlefieldMgr::IsWintergraspFlightAllowed(")
b=s.index("\nvoid BattlefieldMgr::UpdateWintergraspFlight",a)
code=r'''
#include <cassert>
#include <vector>
#include <map>
#include <memory>
using uint32=unsigned;
struct DBCManager{static bool IsInArea(unsigned a,unsigned z){return a==z||(z==4197&&a==4538);}};
struct Map{unsigned id=571;unsigned GetId()const{return id;}};
struct Battlefield{bool enabled=true,war=false;unsigned zone=4197;unsigned GetZoneId()const{return zone;}bool IsEnabled()const{return enabled;}bool CanFlyIn()const{return !war;}};
struct BattlefieldMgr{std::map<Map const*,std::vector<std::unique_ptr<Battlefield>>> _battlefieldsByMap;bool IsWintergraspFlightAllowed(uint32,uint32)const;};
'''+s[a:b]+r'''
int main(){BattlefieldMgr m;Map map;assert(m.IsWintergraspFlightAllowed(571,4197));assert(m.IsWintergraspFlightAllowed(571,4538));assert(!m.IsWintergraspFlightAllowed(0,4197));assert(!m.IsWintergraspFlightAllowed(571,210));auto bf=std::make_unique<Battlefield>();auto p=bf.get();m._battlefieldsByMap[&map].push_back(std::move(bf));assert(m.IsWintergraspFlightAllowed(571,4538));p->war=true;assert(!m.IsWintergraspFlightAllowed(571,4197));assert(!m.IsWintergraspFlightAllowed(571,4538));p->enabled=false;assert(m.IsWintergraspFlightAllowed(571,4538));p->enabled=true;p->war=false;assert(m.IsWintergraspFlightAllowed(571,4197));}
'''
with tempfile.TemporaryDirectory(prefix="vcore-wg-flight-") as d:
p=Path(d);(p/"test.cpp").write_text(code)
subprocess.run(["c++","-std=c++17",str(p/"test.cpp"),"-o",str(p/"test")],check=True)
subprocess.run([str(p/"test")],check=True)
print("WG main/subarea: absent, disabled, peace, war, end and unrelated areas PASS")
@@ -0,0 +1,29 @@
"""Run the production flying-area cast check, including explicit aura restrictions."""
from pathlib import Path
import subprocess,tempfile
root=Path(__file__).resolve().parents[2]
s=(root/"src/server/game/Spells/SpellInfo.cpp").read_text()
a=s.index(" // continent limitation (virtual continent)",s.index("SpellInfo::CheckLocation"))
b=s.index(" // raid instance limitation",a)
code=r'''
#include <cassert>
#include <vector>
using uint32=unsigned;
enum class AreaMountFlags{None=0,AllowFlyingMounts=2};
template<class T>struct EnumFlag{int bits;EnumFlag(T v):bits(int(v)){}void operator|=(T v){bits|=int(v);}bool HasFlag(T v){return bits&int(v);}};
constexpr int SPELL_AURA_MOUNT_RESTRICTIONS=1,SPELL_ATTR4_ONLY_FLYING_AREAS=2,SPELL_FAILED_INCORRECT_AREA=3;
struct AuraEffect{int mask;int GetMiscValue()const{return mask;}};
struct Player{bool restrict=false;std::vector<AuraEffect const*> auras;bool HasAuraType(int)const{return restrict;}auto const& GetAuraEffectsByType(int)const{return auras;}};
struct AreaTableEntry{int flags=13;EnumFlag<AreaMountFlags> GetMountFlags()const{return AreaMountFlags(flags);}} area;
struct Areas{AreaTableEntry const* LookupEntry(unsigned){return &area;}} sAreaTableStore;
struct Battlefields{bool war=false;bool IsWintergraspFlightAllowed(unsigned map,unsigned id){return map==571&&(id==4197||id==4538)&&!war;}} battlefield;auto sBattlefieldMgr=&battlefield;
struct SpellInfo{bool HasAttribute(int)const{return true;}int CheckLocation(unsigned map_id,unsigned area_id,Player const* player){
'''+s[a:b]+r'''
return 0;}};
int main(){SpellInfo s;Player p;assert(s.CheckLocation(571,4197,&p)==0);assert(s.CheckLocation(571,4538,&p)==0);battlefield.war=true;assert(s.CheckLocation(571,4538,&p)==SPELL_FAILED_INCORRECT_AREA);battlefield.war=false;assert(s.CheckLocation(571,4538,&p)==0);assert(s.CheckLocation(571,210,&p)==SPELL_FAILED_INCORRECT_AREA);p.restrict=true;AuraEffect noFlight{1};p.auras={&noFlight};assert(s.CheckLocation(571,4197,&p)==SPELL_FAILED_INCORRECT_AREA);AuraEffect flight{3};p.auras={&flight};assert(s.CheckLocation(571,4197,&p)==0);}
'''
with tempfile.TemporaryDirectory(prefix="vcore-wg-mount-cast-") as directory:
p=Path(directory);(p/"test.cpp").write_text(code)
subprocess.run(["c++","-std=c++17",str(p/"test.cpp"),"-o",str(p/"test")],check=True)
subprocess.run([str(p/"test")],check=True)
print("Production mount cast: WG/main/subarea peace/war/end, unrelated zone, aura precedence PASS")
@@ -0,0 +1,40 @@
"""Exercise ZM graveyard ownership with production registration and member setup."""
from pathlib import Path
import subprocess, tempfile
root = Path(__file__).resolve().parents[2]
header = (root / "src/server/scripts/OutdoorPvP/OutdoorPvPZM.h").read_text()
script = (root / "src/server/scripts/OutdoorPvP/OutdoorPvPZM.cpp").read_text()
source = (root / "src/server/game/OutdoorPvP/OutdoorPvP.cpp").read_text()
member = next(line for line in header.splitlines() if " m_Graveyard" in line and "State" not in line)
a = script.index(" m_Graveyard =", script.index("bool OutdoorPvPZM::SetupOutdoorPvP"))
b = script.index("\n", script.index("AddCapturePoint(",a))
setup = script[a:b]
a = source.index("void OutdoorPvP::AddCapturePoint(")
b = source.index("\nOPvPCapturePoint* OutdoorPvP::GetCapturePoint",a)
registration = source[a:b]
code = r'''
#include <map>
#include <memory>
#include <cassert>
#define TC_LOG_ERROR(...) ((void)0)
struct OutdoorPvP;
int destroyed=0;
struct OPvPCapturePoint {unsigned m_capturePointSpawnId=0; virtual ~OPvPCapturePoint(){++destroyed;}};
struct OPvPCapturePointZM_Graveyard:OPvPCapturePoint {explicit OPvPCapturePointZM_Graveyard(OutdoorPvP*){}};
struct OutdoorPvP {
using OPvPCapturePointMap=std::map<unsigned,std::unique_ptr<OPvPCapturePoint>>;
OPvPCapturePointMap m_capturePoints;
virtual ~OutdoorPvP()=default;
void AddCapturePoint(OPvPCapturePoint* cp);
};
''' + registration + "struct OutdoorPvPZM: OutdoorPvP {\n" + member + "\nvoid setup(){\n" + setup + r'''
}
};
int main(){for(int i=0;i<100;++i){auto p=std::make_unique<OutdoorPvPZM>();p->setup();assert(p->m_capturePoints.size()==1);assert(p->m_capturePoints.begin()->second.get()==p->m_Graveyard);}assert(destroyed==100);}
'''
with tempfile.TemporaryDirectory(prefix="vcore-zm-ownership-") as directory:
path=Path(directory)
(path/"test.cpp").write_text(code)
subprocess.run(["c++","-std=c++17","-fsanitize=address","-fno-omit-frame-pointer",str(path/"test.cpp"),"-o",str(path/"test")],check=True)
subprocess.run([str(path/"test")],check=True)
print("ZM graveyard: 100 lifecycles, exactly one destruction each, AddressSanitizer PASS")