Implement clientless transports, Lua reload and same-map portals

Add native transport recording and execution with grid lifecycle handling, clientless teleport acknowledgements, and regression tests. Support targeted same-map portal imports and preserve disabled taxi nodes. Document live transport and portal verification and the remaining portal inventory.
This commit is contained in:
Nico committed 2026-10-26 11:27:56 +01:00
1 parent 01a9ba4b0b
commit 1164a72ea3
40 files changed
+4533 -163

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
# Lua im laufenden Testserver ändern
`.clientless luareload 26` liest Arthorans Profil und Adventure-Skript erneut von
der Festplatte und führt das Verhalten einmal aus. In der Serverkonsole entfällt
der führende Punkt. Der Befehl benötigt GM-Rechte und funktioniert für jeden
eingeloggten, verwalteten clientlosen Spieler anhand seiner GUID.
Die Antwort enthält den tatsächlich verwendeten absoluten Skriptpfad. Es gelten
weiterhin die vorhandenen Fallbacks: `adventure/profiles/<name>.lua` oder
`adventure/profiles/generic.lua` sowie `adventure/overrides/<name>.lua` oder
`adventure/generic.lua`. Die Engine hält keine kompilierten Skripte im Cache.
Für Entwicklung kann `worldserver.conf` direkt auf die Quelldateien zeigen:
```ini
Clientless.ScriptRoot = "/home/wow_admin/wow_cata_server/source/TrinityCore/src/server/scripts/Custom/clientless"
```
Der Standard bleibt `scripts/clientless`, relativ zum Arbeitsverzeichnis des
Worldservers. Eine nichtleere Umgebungsvariable `VCORE_CLIENTLESS_SCRIPT_DIR`
hat Vorrang. Eine Änderung des Stammverzeichnisses erfordert einen Neustart;
anschließende Lua-Dateiänderungen benötigen weder Build noch Installation oder
Neustart. Die vorhandene Installation der Lua-Dateien bleibt für eigenständige
Serverinstallationen erhalten.
Der Reload setzt einen untätigen Bot mit leerer Aktionswarteschlange voraus.
Laufende Reisen, Flüge, Teleports und Passagiere auf Transportmitteln werden
abgewiesen, ohne diese abzubrechen. Der Befehl hängt daher keine zusätzliche
Reise an eine bereits laufende Reise an. Ein erfolgreicher Reload kann sofort
die im geänderten Skript eingereihten Aktionen starten.
Profil und Verhalten werden zusammen geladen. Bei Lade- oder Laufzeitfehlern
bleibt das bisherige Profil erhalten. Während des Reloads angeforderte Aktionen
und Intent-Änderungen werden erst nach Erfolg übernommen. Lua-Logmeldungen
können auch aus einem später fehlgeschlagenen Reload stammen. Fehlerdetails
stehen im Serverlog; das Ergebnis wird zusätzlich als `LUA_RELOAD` im
Navigationslog protokolliert. Lokale Variablen des alten Skriptaufrufs werden
nicht fortgeführt; das entspricht dem bisherigen einmaligen Login-Aufruf.
Regressionstest: `python3 tests/clientless/test_lua_reload.py` (C++-Compiler und
Lua-5.4-Entwicklungspaket erforderlich).
+65
View File
@@ -0,0 +1,65 @@
# Rut’theran / Darnassus: Graphkorrektur vom 26.10.2026
`taxi_585` wurde auf GM-Anweisung ausschließlich im CPC-TravelGraph und dessen
Weltpunkttabelle deaktiviert. Der Punkt bei (8334.76, 1029.83, 22.79) ist kein
regulärer Spieler-Flugpunkt. Es waren keine Kanten mit diesem Node verbunden.
Native Taxi-/NPC-Definitionen bleiben erhalten. Die explizite Testmigration
steht in `sql/custom/world/2026_10_26_00_clientless_disable_taxi_585.sql`.
Der Taxi-Importer behält bestehende `enabled=0`-Flags und deren Kommentare bei,
anstatt sie bei jedem Neuimport zu aktivieren. Neue Taxi-Datensätze werden wie
bisher aktiviert angelegt. Ein SQL-Regressionstest führt die tatsächlichen
UPDATE-Anweisungen mit aktiven und deaktivierten Datensätzen aus:
`python3 tests/clientless/test_taxi_import_disabled.py`.
## Fehlende Portale
Der frühere Portalimport übersprang gleichkartige Übergänge, und der Executor
lehnte sie ausdrücklich ab. Rut’theran und Darnassus liegen beide auf Karte 1.
Die allgemeine Clientless-Near-Teleport-Bestätigung ist seit der
Transportintegration vorhanden und wird jetzt auch vom Portal-Executor benutzt.
Portalreisen durchlaufen weiterhin den normalen AreaTrigger-Handler mit dessen
Zugangsprüfungen. Vor Fortsetzung muss der Teleport abgeschlossen sein und die
Position höchstens fünf Yards vom tatsächlichen Ziel entfernt liegen. Dadurch
kann ein verweigerter gleichkartiger Teleport nicht als Erfolg gelten.
`.clientless portal import <triggerId>` erlaubt nun einen gezielten Import,
einschließlich gleichkartiger Portale. Der bisherige Bulk-Import ohne Argument
behält seinen Umfang (Kartenwechsel) bei. `portal import` und `portal info` sind
mit denselben GM-Rechten auch über die Konsole nutzbar.
Aus den installierten DBC-/DB-Daten abgeleitete Nodes, alle auf Karte 1:
| Ort | Node | X | Y | Z |
| --- | --- | --- | --- | --- |
| Rut’theran, nach Darnassus | portal_at_542_source | 8393.800 | 1003.900 | 30.191 |
| Darnassus, Ankunft | portal_at_542_target | 9946.250 | 2612.970 | 1316.490 |
| Darnassus, nach Rut’theran | portal_at_527_source | 9947.480 | 2630.040 | 1318.600 |
| Rut’theran, Ankunft | portal_at_527_target | 8380.560 | 998.536 | 29.129 |
Gerichtete Portalkanten: 542_source → 542_target (ID 1747) und
527_source → 527_target (ID 1746), Kosten jeweils 1. Groundanschlüsse werden
separat aufgezeichnet oder vom Planner vollständig per MMAP validiert.
## Verifikation
Build, Installation und Regressionstests bestanden. Echter Live-Test mit
Arthoran: Trigger 542 um 10:26:22, Trigger 527 um 10:27:17, jeweils
`EDGE_COMPLETE`, `arrivalError=0.00`, Recovery 0. Die Rückfahrt nutzte zunächst
eine validierte MMAP-Brücke von `portal_at_542_target` zum 17,24 Yards entfernten
`portal_at_527_source`; danach wurde der reguläre AreaTrigger ausgeführt.
`tests/clientless/test_portal_transfer.py` prüft den tatsächlichen
Handler-/ACK-/Zielprüfungsblock mit Core-Doubles: Near und Far, abgewiesener
Same-Map-Teleport, fehlender ACK-Abschluss und falscher Sessiontyp.
Die existierenden Transport- und Lua-Reload-Regressionstests bestanden ebenfalls.
Ausgangsdaten, neu erzeugte Portalnodes und vorheriges Arthoran-Lua liegen unter
`/tmp/vcore-ruttheran-fix.yxm90j91`. Ein laufender GM-Aufnahmebuffer wurde vor
dem Neustart ausdrücklich als gespeichert bestätigt und im Log nachgewiesen.
Die abschließend installierte Build-ID ist
`f2873b12546ada19e68aa3b6b0d8e7823fe816e5` (einschließlich korrigierter
`portal info`-Anzeige). Ready-Meldung, laufender Prozess 42675 und Port 8085
wurden nach dem finalen Start geprüft. Arthoran wurde nach beiden Tests sauber
ausgeloggt; das vorherige Lua-Skript ist wiederhergestellt.
File diff suppressed because it is too large. Load diff
+99
View File
@@ -0,0 +1,99 @@
# Vom Bulk-Import übersprungene gleichkartige Portalübergänge
Bestandsaufnahme vom 26.10.2026, ausschließlich lesender Abgleich der lokalen Testdatenbank mit den installierten AreaTrigger-/Map-DBC-Dateien.
Teleportdefinitionen: 316; davon gleichkartig: 60, kartenwechselnd: 256, ohne passenden DBC-Trigger: 0.
Aktuell vollständig aktiv im Graph: 2 gleichkartige Übergänge. Der Bulk-Importer überspringt sie weiterhin; gezielter Import ist möglich. Keine Daten wurden verändert.
## Einordnung der 58 zusätzlich fehlenden Übergänge
Alle 58 fehlen sowohl als Source-Node und Target-Node als auch als Portal-Kante: insgesamt 116 fehlende Nodes und 58 fehlende gerichtete Kanten. Die bereits ergänzten Trigger 527 und 542 sind vollständig aktiv.
| Gruppe nach DB-Bezeichnung/Kartentyp | Trigger | Anzahl |
| --- | --- | --- |
| Sturmwind, Magierturm | 702, 704 | 2 |
| Immersangwald, Duskwither-Teleporter | 4233, 4267 | 2 |
| Dalaran, Brunnen | 5273 | 1 |
| Acherus/Ebon Hold, interne Ebenenübergänge | 5127–5138 | 12 |
| Hyjal/Feuerlande, Outdoor-Übergänge | 5879, 5880, 5893, 5895, 5931, 5937 | 6 |
| Ödland, Klippen-/Anlegerübergänge | 6321, 6322 | 2 |
| Tausend Nadeln, Test of Faith | 943 | 1 |
| Scherbenwelt, Invasion Point | 4561, 4562 | 2 |
| Hyjal-Schlachtzug, Basisübergänge | 4311–4313 | 3 |
| Hallen der Reflexion, Türübergang | 5740 | 1 |
| Verlorene Inseln, Volcanoth/Phase 8+ | 5926 | 1 |
| Arena-Start-/Umsetzungstrigger auf fünf Arenakarten | siehe vollständige Liste | 25 |
Diese Gruppen sind eine Einordnung anhand der gespeicherten Namen und nativen Kartentypen. Sie ersetzen keine Prüfung der Quest-, Fraktions-, Phasen- und Instanzbedingungen. Insbesondere Arena-Starttrigger sind keine frei nutzbaren überregionalen Reisekanten.
Als nächsten gezielten Kandidaten zuerst **702/704 in Sturmwind** prüfen, danach nach Bedarf Duskwither und den Dalaran-Brunnen. Den übrigen Bestand erst nach Prüfung seiner tatsächlichen Spielmechanik und Zugänglichkeit ergänzen. Es wurde nichts importiert, aktiviert oder am laufenden Server geändert.
## Vollständiger Bestand der 60 gleichkartigen Übergänge
| Trigger | Name laut DB | Karte | Graphstatus | Source X/Y/Z | Target X/Y/Z |
| --- | --- | --- | --- | --- | --- |
| 527 | Darnassus Exit Target | 1 – Kalimdor | aktiv | 9947.48 / 2630.04 / 1318.60 | 8380.56 / 998.54 / 29.13 |
| 542 | Darnassus Entrance Target | 1 – Kalimdor | aktiv | 8393.80 / 1003.90 / 30.19 | 9946.25 / 2612.97 / 1316.49 |
| 702 | Stormwind - Mage Exit Target | 0 – Eastern Kingdoms | fehlt/unvollständig | -9020.67 / 890.10 / 34.00 | -9014.94 / 873.33 / 148.62 |
| 704 | Stormwind - Mage Entrance Target | 0 – Eastern Kingdoms | fehlt/unvollständig | -9017.37 / 877.30 / 152.00 | -9016.97 / 885.44 / 29.62 |
| 943 | Thousand Needles - Test of Faith (Target) | 1 – Kalimdor | fehlt/unvollständig | -4950.74 / -1595.14 / -3.02 | -5191.27 / -2802.59 / -7.21 |
| 4233 | Eversong Woods - Duskwither UP Target | 530 – Outland | fehlt/unvollständig | 9336.22 / -7883.46 / 74.91 | 9331.49 / -7812.27 / 136.57 |
| 4267 | Eversong Woods - Duskwither DOWN Target | 530 – Outland | fehlt/unvollständig | 9285.97 / -7916.01 / 176.13 | 9334.03 / -7880.02 / 74.91 |
| 4311 | CoT - Hyjal - Human Base - Teleport Target | 534 – The Battle for Mount Hyjal | fehlt/unvollständig | 4162.02 / -4227.07 / 869.18 | 5066.79 / -1791.90 / 1321.65 |
| 4312 | CoT - Hyjal - Orc Base - Teleport Target | 534 – The Battle for Mount Hyjal | fehlt/unvollständig | 4284.15 / -4138.85 / 866.81 | 5499.96 / -2756.80 / 1488.96 |
| 4313 | CoT - Hyjal - Night Elf Base - Teleport Target | 534 – The Battle for Mount Hyjal | fehlt/unvollständig | 4203.64 / -4103.24 / 878.36 | 5163.02 / -3428.31 / 1627.61 |
| 4561 | Invasion Point, Cataclysm (Return Point) | 530 – Outland | fehlt/unvollständig | -2829.78 / 1953.76 / 202.46 | -3278.63 / 2831.31 / 123.01 |
| 4562 | Invasion Point, Cataclysm (Return Point) | 530 – Outland | fehlt/unvollständig | -2745.24 / 2730.42 / 127.78 | -3278.63 / 2831.31 / 123.01 |
| 4917 | Arena - Nagrand - Teleport Target - Game On | 559 – Nagrand Arena | fehlt/unvollständig | 4054.15 / 2923.70 / -40.00 | 4054.57 / 2923.23 / 13.82 |
| 4919 | Arena - Bladesedge - Teleport Target - Game On | 562 – Blade's Edge Arena | fehlt/unvollständig | 6188.01 / 320.00 / 20.00 | 6238.89 / 264.15 / 0.89 |
| 4921 | Arena - Bladesedge - Teleport Target - Game On | 562 – Blade's Edge Arena | fehlt/unvollständig | 6288.22 / 210.00 / 20.00 | 6238.89 / 264.15 / 0.89 |
| 4922 | Arena - Bladesedge - Teleport Target - Game On | 562 – Blade's Edge Arena | fehlt/unvollständig | 6214.87 / 200.26 / 20.00 | 6238.89 / 264.15 / 0.89 |
| 4923 | Arena - Bladesedge - Teleport Target - Game On | 562 – Blade's Edge Arena | fehlt/unvollständig | 6187.77 / 196.88 / 20.00 | 6238.89 / 264.15 / 0.89 |
| 4924 | Arena - Bladesedge - Teleport Target - Game On | 562 – Blade's Edge Arena | fehlt/unvollständig | 6264.76 / 329.75 / 3.41 | 6238.89 / 264.15 / 0.89 |
| 4925 | Arena - Bladesedge - Teleport Target - Game On | 562 – Blade's Edge Arena | fehlt/unvollständig | 6290.77 / 333.41 / 20.00 | 6238.89 / 264.15 / 0.89 |
| 4927 | Arena - Undercity - Teleport Target - Game On | 572 – Ruins of Lordaeron | fehlt/unvollständig | 1200.33 / 1682.09 / 33.08 | 1286.57 / 1667.40 / 39.60 |
| 4928 | Arena - Undercity - Teleport Target - Game On | 572 – Ruins of Lordaeron | fehlt/unvollständig | 1223.61 / 1630.81 / 32.61 | 1286.57 / 1667.40 / 39.60 |
| 4929 | Arena - Undercity - Teleport Target - Game On | 572 – Ruins of Lordaeron | fehlt/unvollständig | 1359.18 / 1692.25 / 37.07 | 1286.57 / 1667.40 / 39.60 |
| 4930 | Arena - Undercity - Teleport Target - Game On | 572 – Ruins of Lordaeron | fehlt/unvollständig | 1371.51 / 1649.08 / 34.51 | 1286.57 / 1667.40 / 39.60 |
| 4931 | Arena - Undercity - Teleport Target - Game On | 572 – Ruins of Lordaeron | fehlt/unvollständig | 1299.76 / 1554.74 / 40.00 | 1286.57 / 1667.40 / 39.60 |
| 4932 | Arena - Undercity - Teleport Target - Game On | 572 – Ruins of Lordaeron | fehlt/unvollständig | 1274.51 / 1774.68 / 27.04 | 1286.57 / 1667.40 / 39.60 |
| 4933 | Arena - Undercity - Teleport Target - Game On | 572 – Ruins of Lordaeron | fehlt/unvollständig | 1246.06 / 1743.62 / 40.00 | 1286.57 / 1667.40 / 39.60 |
| 4934 | Arena - Undercity - Teleport Target - Game On | 572 – Ruins of Lordaeron | fehlt/unvollständig | 1305.44 / 1754.14 / 40.00 | 1286.57 / 1667.40 / 39.60 |
| 4935 | Arena - Undercity - Teleport Target - Game On | 572 – Ruins of Lordaeron | fehlt/unvollständig | 1321.62 / 1586.98 / 40.00 | 1286.57 / 1667.40 / 39.60 |
| 4936 | Arena - Undercity - Teleport Target - Game On | 572 – Ruins of Lordaeron | fehlt/unvollständig | 1269.12 / 1579.99 / 40.00 | 1286.57 / 1667.40 / 39.60 |
| 4941 | Arena - Undercity - Teleport Target - Game On | 572 – Ruins of Lordaeron | fehlt/unvollständig | 1285.36 / 1667.68 / 10.00 | 1286.57 / 1667.40 / 39.60 |
| 4944 | Arena - Bladesedge - Teleport Target - Game On | 562 – Blade's Edge Arena | fehlt/unvollständig | 6237.84 / 261.36 / -20.00 | 6238.89 / 264.15 / 0.89 |
| 5127 | Ebon Hold (E.K.) Top Return Target | 0 – Eastern Kingdoms | fehlt/unvollständig | 2479.34 / -5495.73 / 421.84 | 2462.76 / -5598.19 / 550.22 |
| 5128 | Ebon Hold (E.K.) Top West Target | 0 – Eastern Kingdoms | fehlt/unvollständig | 2469.29 / -5545.84 / 546.10 | 2472.67 / -5530.10 / 420.64 |
| 5129 | Ebon Hold (E.K.) Top Return Target | 0 – Eastern Kingdoms | fehlt/unvollständig | 2362.55 / -5574.54 / 421.84 | 2462.76 / -5598.19 / 550.22 |
| 5130 | Ebon Hold (E.K.) Top South Target | 0 – Eastern Kingdoms | fehlt/unvollständig | 2412.68 / -5584.65 / 546.10 | 2397.37 / -5581.06 / 420.64 |
| 5131 | Ebon Hold (E.K.) Top Return Target | 0 – Eastern Kingdoms | fehlt/unvollständig | 2558.37 / -5612.56 / 421.83 | 2462.76 / -5598.19 / 550.22 |
| 5132 | Ebon Hold (E.K.) Top North Target | 0 – Eastern Kingdoms | fehlt/unvollständig | 2508.14 / -5602.50 / 546.10 | 2522.45 / -5605.70 / 420.64 |
| 5133 | Ebon Hold (E.K.) Top Return Target | 0 – Eastern Kingdoms | fehlt/unvollständig | 2441.37 / -5691.42 / 421.83 | 2462.76 / -5598.19 / 550.22 |
| 5134 | Ebon Hold (E.K.) Top East Target | 0 – Eastern Kingdoms | fehlt/unvollständig | 2451.50 / -5641.27 / 546.10 | 2448.02 / -5655.82 / 420.64 |
| 5135 | Ebon Hold (E.K.) Middle -> Bottom Target | 0 – Eastern Kingdoms | fehlt/unvollständig | 2427.67 / -5548.64 / 542.09 | 2435.77 / -5610.34 / 366.82 |
| 5136 | Ebon Hold (E.K.) Middle -> Bottom Target | 0 – Eastern Kingdoms | fehlt/unvollständig | 2505.39 / -5563.90 / 542.09 | 2435.77 / -5610.34 / 366.82 |
| 5137 | Ebon Hold (E.K.) Bottom -> Middle Target | 0 – Eastern Kingdoms | fehlt/unvollständig | 2419.03 / -5622.17 / 542.07 | 2472.90 / -5530.64 / 420.64 |
| 5138 | Ebon Hold (E.K.) Bottom -> Ground Target | 0 – Eastern Kingdoms | fehlt/unvollständig | 2486.99 / -5636.42 / 542.14 | 2415.16 / -5733.04 / 153.92 |
| 5273 | Dalaran Well teleporter | 571 – Northrend | fehlt/unvollständig | 5886.27 / 651.45 / 640.00 | 5878.56 / 666.42 / 615.29 |
| 5326 | Arena - Dalaran Arena - Teleport Target | 617 – Dalaran Sewers | fehlt/unvollständig | 1330.00 / 800.00 / -10.00 | 1291.57 / 792.97 / 8.11 |
| 5328 | Arena - Dalaran Arena - Teleport Target | 617 – Dalaran Sewers | fehlt/unvollständig | 1289.72 / 736.61 / 3.16 | 1291.57 / 792.97 / 8.11 |
| 5329 | Arena - Dalaran Arena - Teleport Target | 617 – Dalaran Sewers | fehlt/unvollständig | 1292.05 / 845.27 / 5.63 | 1291.57 / 792.97 / 8.11 |
| 5330 | Arena - Dalaran Arena - Teleport Target | 617 – Dalaran Sewers | fehlt/unvollständig | 1238.08 / 780.11 / 3.16 | 1291.57 / 792.97 / 8.11 |
| 5331 | Arena - Dalaran Arena - Teleport Target | 617 – Dalaran Sewers | fehlt/unvollständig | 1348.50 / 792.83 / 3.82 | 1291.57 / 792.97 / 8.11 |
| 5447 | Arena - Orgrimmar - Teleport Target 3 | 618 – The Ring of Valor | fehlt/unvollständig | 801.66 / -278.86 / -24.00 | 763.37 / -284.29 / 28.28 |
| 5740 | Halls of Reflection (The Impenetrable Door) | 668 – Halls of Reflection | fehlt/unvollständig | 5361.10 / 2061.02 / 707.70 | 5354.01 / 2053.53 / 707.70 |
| 5879 | Firelands Forgeworks (Exit) | 1 – Kalimdor | fehlt/unvollständig | 5042.17 / -2026.51 / 1148.98 | 5036.41 / -2045.99 / 1368.39 |
| 5880 | Firelands Forgeworks (Enterance) | 1 – Kalimdor | fehlt/unvollständig | 5030.84 / -2036.30 / 1378.09 | 5029.45 / -2029.36 / 1148.97 |
| 5893 | Hyjal - Teleport from Seat of the Prophets | 1 – Kalimdor | fehlt/unvollständig | 4320.92 / -3288.20 / 1036.05 | 3948.62 / -2818.22 / 618.75 |
| 5895 | Hyjal - Teleport From Sulfuron Spire | 1 – Kalimdor | fehlt/unvollständig | 3943.90 / -2807.49 / 618.75 | 4316.05 / -3282.51 / 1035.53 |
| 5926 | The Lost Isles - Volcanoth's Lair - Teleport Target - Phase 8+ | 659 – Lost Isles Volcano Eruption | fehlt/unvollständig | 1429.99 / 1233.86 / 124.60 | 1172.59 / 1094.79 / 119.23 |
| 5931 | The Crucible of Flame (Enterance) | 1 – Kalimdor | fehlt/unvollständig | 4664.00 / -3686.81 / 956.63 | 4679.10 / -3675.53 / 696.45 |
| 5937 | The Crucible of Flame (Exit) | 1 – Kalimdor | fehlt/unvollständig | 4678.15 / -3682.77 / 696.45 | 4653.99 / -3688.44 / 955.26 |
| 6321 | Badlands 4.x - Cliff Teleport Target | 0 – Eastern Kingdoms | fehlt/unvollständig | -6518.68 / -4604.42 / 29.23 | -6547.88 / -4443.24 / 332.08 |
| 6322 | Badlands 4.x - Dock Teleport Target | 0 – Eastern Kingdoms | fehlt/unvollständig | -6551.28 / -4446.46 / 332.83 | -6519.82 / -4608.52 / 28.48 |
Die Namen stammen aus der Teleporttabelle. Sie belegen keine universelle Begehbarkeit oder Freigabe für alle Spieler. Quests, Fraktion, Instanzstatus und Skriptbedingungen müssen vor einem Import geprüft werden.
Maschinenlesbare Daten einschließlich des vollständigen Node-/Kantenstatus: [same-map-portals.json](same-map-portals.json).
+107
View File
@@ -0,0 +1,107 @@
# Erste Schiffsverbindung: Sturmwind – Rut’theran
Auswahl des Nutzers: Schiff ab Sturmwind. Erster Test mit **The Bravery**,
Spawn **2**, Entry **176310**. Die Datenbankbeschreibung nennt noch Auberdine;
die tatsächlichen nativen Halte liegen bei Sturmwind und südlich des
Rut’theran-Teleportziels. Für die Aufnahmen ist der echte Anleger maßgeblich.
Live über `clientless transport services 2` geprüft:
| Halt | Karte | Fahrzeugursprung X / Y / Z | Haltezeit |
| --- | --- | --- | --- |
| Sturmwind | 0 | -8650.719 / 1346.051 / 0.000 | 60 s |
| Kalimdor, Anleger bei Rut’theran | 1 | 8162.587 / 1005.365 / 0.000 | 60 s |
Umlaufzeit: 235782 ms, zwei Halte und zwei native Pfadabschnitte. Diese
Koordinaten bezeichnen den Schiffsrumpf-Ursprung, nicht den Groundnode.
## Aufnahme in Sturmwind
Am festen Boden unmittelbar vor dem Übergang zum Schiff positionieren:
```text
.clientless node add ship_sw_br ship
.clientless transport near 50
```
Bei frisch angedocktem Schiff Spawn 2 beginnen, normal an Bord zu einem
sicheren ebenen Deckplatz laufen und dort speichern:
```text
.clientless transport record start ship_sw_br 2
.clientless transport record stop bravery_center
```
## Aufnahme auf Kalimdor
Mit demselben Schiff mitfahren. Am Ziel aussteigen und direkt vor dem
Übergang auf festem Boden den gemeinsamen Haltestellen-Node erstellen:
```text
.clientless node add ship_ruttheran_br ship
```
Während eines vollständigen Halts desselben Schiffs erneut an Bord und zum
gleichen lokalen Stehplatz gehen:
```text
.clientless transport record start ship_ruttheran_br 2
.clientless transport record stop bravery_center
```
Danach Aufnahmen prüfen und Verbindung anlegen:
```text
.clientless transport stops
.clientless transport link ship_sw_br ship_ruttheran_br ship 100 both
```
Der vorläufige Graphkostenwert 100 dient dem isolierten Transporttest. Erst
nach funktioneller Abnahme und Kostenkalibrierung konkurrierende Fernrouten
bewerten. Die Landanschlüsse zum restlichen Graphen separat aufnehmen;
keine zusätzlichen Ein-/Ausgangsnodes für denselben Anleger erstellen.
## Abnahmekriterien
Nach zwei kompatiblen echten Übergangsaufnahmen Arthoran an `ship_sw_br`
positionieren und `travel_to_node` nach `ship_ruttheran_br` ausführen. Prüfen:
Warten, Boarding, Passenger, nativer Kartenwechsel 0→1 mit vollständigem
Clientless-ACK, neue Transportkopie bei identischer Spawn-ID, Ausstieg,
Landnode erreicht. Danach Gegenrichtung sowie Anschluss an Groundroute.
Die generische Typ-15-Unterstützung und der zusätzliche Regressionstest für
den Kartenwechsel sind vorhanden. Stand der echten Schiffsfahrten siehe
Live-Prüfung unten. Danach folgt der Zeppelin; Fahrstühle sind zurückgestellt.
## Live-Prüfung am 26.10.2026
Beide Übergänge wurden durch den GM aufgenommen und geladen:
- `ship_sw_br`: 11 Punkte, 17,72 Yards, Karte 0.
- `ship_ruttheran_br`: 4 Punkte, 7,76 Yards, Karte 1. Node-Typ bei Anlage
`ground`; der Executor verwendet den Fahrtkantentyp `ship`.
- Beide Spawn 2, Entry 176310 und Stand `bravery_center`.
Die native Kompatibilitätsprüfung bestand; bidirektionale Kante 1743 wurde
angelegt. Hinfahrt ohne Recovery: Waiting 09:15:04, Boarding 09:16:26,
Riding/Passenger 09:16:31, Leaving 09:18:23, Arrived auf Land 09:18:25.
Die nächste Planung begann nachweislich auf Karte 1.
Rückfahrt wurde ohne Logout per `.clientless luareload 26` um 09:32:29
gestartet. Der Planer wählte Kante 1743 mit `reverse=1`. Boarding 09:34:06,
Riding/Passenger 09:34:07, Leaving 09:36:05, Arrived auf Land in Sturmwind
09:36:08. Auch die Rückfahrt wurde ohne Recovery abgeschlossen. Beide
Richtungen nutzten die normalen Schiffs- und Passenger-Mechaniken inklusive
Kartenwechsel. Es wurden während der Reisen keine Charakterpositionen als
Test-Abkürzung manipuliert.
Worldserver-PID 37385 blieb während beider Fahrten unverändert. Arthoran
blieb nach Abschluss online am Sturmwind-Anleger. Das Hinreise-Lua wurde
auf der Festplatte wiederhergestellt, ohne eine weitere Aktion auszuführen.
Die separat vom GM aufgezeichneten Land-/Portalanschlüsse Richtung Darnassus
sind noch nicht Bestandteil dieses isolierten Seestrecken-Tests.
Ausgangsposition und vorheriges Lua liegen unter
`/tmp/vcore-ship-ride.voj3k_8t`. Vorwärts-Lua ebenfalls dort gesichert.
Der Nutzer untersagte für diese Testphase Shutdown und Neustart bis zu einer
ausdrücklichen Freigabe; der laufende Server wurde durchgehend beibehalten.
@@ -0,0 +1,78 @@
# Allgemeiner Lifecycle animierter GameObject-Transporte
## Ursache
Die Tiefenbahn besteht aus sechs `GAMEOBJECT_TYPE_TRANSPORT`-GameObjects
(Typ 11). Drei Wagen besitzen ihre Datenbank-Spawnposition in Eisenschmiede,
drei in Sturmwind. Diese GameObjects werden beim Erstellen bereits über
`setActive(true)` für Aktualisierung und einen Unload-Lock ihres Spawn-Grids
registriert. Vorher müssen sie jedoch vom normalen Grid-Loader erzeugt werden.
Bei ausschließlich in Sturmwind anwesenden Spielern blieb das entfernte
Spawn-Grid ungeladen. Der dort startende Zug existierte daher serverseitig noch
nicht. Das betrifft Netzwerkspieler ebenso wie clientlose Spieler. Ein Besuch
in Eisenschmiede lud die fehlenden Wagen und ließ den Fehler bis zum nächsten
Neustart verschwinden.
## Änderung
`ObjectMgr::LoadGameObjects` indexiert nach Abschluss der vorhandenen
Validierungen reguläre Typ-11-Spawns nach Karte. Ereignisgebundene Spawns
bleiben außerhalb dieses Index und behalten die bisherige Ereignisverwaltung.
`MapManager` ruft nach Initialisierung der Respawn-Daten beziehungsweise der
Instanz-/Battleground-Daten `Map::LoadTransportGameObjectGrids` auf. Diese
Funktion lädt ausschließlich die Spawn-Grids der indexierten Transporte für die
jeweilige Karte und Spawnmaske. Das Laden erfolgt über `EnsureGridLoaded`:
vorhandene Pools, Spawn-Gruppen, Respawn- und Phasenmechaniken gelten weiter.
Bereits geladene Grids erzeugen keine zusätzlichen Objektinstanzen.
Die Änderung verwendet keine CPC-, AdventureBot- oder TravelGraph-Komponenten.
Die getrennt verwalteten Typ-15-Transporte (Schiffe/Zeppeline) bleiben im
vorhandenen Transport-Lifecycle. Es werden keine Transportfahrpläne verändert.
Im Testdatenbestand liegen zwischen 1 und 47 Typ-11-Spawns pro betroffener
Karte, sechs davon auf Karte 369. Benachbarte Wagen teilen sich Spawn-Grids.
Durch das frühe Laden werden auch die normalen Inhalte dieser Grids früher
erzeugt; ein vollständiges Vorladen sämtlicher Kartengrids erfolgt nicht.
## Prüfung
`python3 tests/clientless/test_transport_grid_loading.py` prüft die tatsächlichen
neuen Indexierungs- und Ladeblöcke mit deterministischen Core-Doubles:
beide Stationen ohne Spieler, wiederholtes Laden, andere Karten,
Spawnmasken, entfernte/verschobene Spawns und Ausschluss ereignisgebundener
Objekte sowie Typ-15-Transporte.
Für den entscheidenden Live-Test ist ein frischer Worldserverprozess nötig.
Danach darf zunächst ausschließlich die Sturmwindseite betreten werden.
Alle sechs Spawns 18802–18807 müssen dort bereits über `transport inspect`
auflösbar sein. Zwei zeitlich getrennte Zustandsaufnahmen müssen Bewegung
beider Zuggruppen zeigen. Das erstmalige manuelle Betreten der Gegenseite
wäre für diesen Test eine unzulässige Vorbedingung, da es den ursprünglichen
Fehler verdecken würde.
## Live-Ergebnis, 26.10.2026
Build und Installation bestanden, Build-ID
`28fa55f1b9604a57da4616b23a195c117d2fcc82`. Neuer Worldserverprozess: PID 36683;
Ready-Meldung und Port 8085 geprüft. Vor dem Test war kein GM eingeloggt.
Arthoran wurde ausschließlich an `tram_sw` mit einem untätigen Lua-Skript
eingeloggt; ein Besuch am anderen Ende entfiel vollständig.
Die neue Core-Diagnostik meldete für Karte 369 drei geladene Spawn-Grids und
sechs Transportkandidaten. Alle sechs Spawns 18802–18807 waren auflösbar.
Zwei mindestens 20 Sekunden getrennte Aufnahmen bestätigten für jeden Wagen
geänderte Position und Animationsfortschritt. Beide Zuggruppen bewegten sich.
Ergebnis und Konsolenausgaben:
`/tmp/vcore-tram-ride.1i65g2dm/cold-grid-test-result.json` und
`cold-grid-test-console.txt` im selben Verzeichnis. Das vorherige Reise-Lua wurde
anschließend wiederhergestellt; Arthoran blieb online und untätig in Sturmwind.
Eine visuelle Abnahme durch einen normalen WoW-Client bleibt Aufgabe des GM.
Separater offener Befund: Beim vorhergehenden Shutdown des noch unveränderten
Servers (PID 30922, alter Build) erschien `Object.h:448 in GetMap ASSERTION FAILED:
m_currMap`. Der Prozess endete. Diese Assertion trat vor Installation des
Grid-Fixes auf; ihre Ursache ist noch nicht festgestellt. Vorherige Logs liegen
unter `before-grid-fix-restart` im obigen Testverzeichnis. Die Konsolenmeldung
enthielt keinen auswertbaren Stacktrace.
+330
View File
@@ -0,0 +1,330 @@
# Clientless Transport: Aufnahme und Test
Die Integration verwendet reguläre Trinity-Transporte (`TransportBase`). Eine
Haltestelle hat genau einen TravelGraph-Node für Ein- und Ausstieg. Der Übergang
auf das Fahrzeug wird getrennt von den Groundkanten aufgenommen. Es werden
keine Schiffe, Aufzüge oder Bahnen teleportiert oder für den Bot angehalten.
## Datenbank
Die zusätzliche Migration liegt unter
`sql/custom/world/2026_10_25_00_clientless_transport.sql`. Sie wird ausdrücklich
auf die Test-Weltdatenbank angewendet. Die drei neuen Tabellen sind:
- `vcore_clientless_transport_stop`: Haltestelle, Fahrzeug-Entry, Spawn und Dockpose.
- `vcore_clientless_transport_point`: geordnete Übergangspunkte mit Welt-/Transportbezug.
- `vcore_clientless_transport_stand`: benannte Stehplätze relativ zum Fahrzeugmodell.
Bestehende Nodes und Groundkanten bleiben erhalten. Neue Fahrtverbindungen
werden in `vcore_clientless_travel_edge` gespeichert. Ohne zwei gültige,
kompatible Aufnahmen wird eine Transportkante vom Graphen nicht geladen.
Ein Reload kopiert keine Änderungen in eine bereits laufende Fahrt hinein.
## 1. Haltestelle und Fahrzeug bestimmen
Schiffe und Zeppeline können unabhängig vom Aufenthaltsort des GM über die
nativen Routendaten untersucht werden:
```text
.clientless transport services
.clientless transport services 7
```
Die Liste zeigt Spawn, Entry, Modellname, Umlaufzeit, Zahl der geplanten Halte
und Pfadabschnitte. Mit Spawn-ID werden zusätzlich die tatsächlichen
Dockpositionen, Karten, Ankunftszeitpunkte im Umlauf und Haltezeiten ausgegeben.
Diese Daten stammen aus den geladenen Transportpfaden, nicht aus möglicherweise
veralteten `transports.name`-Beschreibungen. Die Dockposition bezeichnet den
Fahrzeugursprung; den Haltestellen-Node weiterhin auf begehbarem festen Boden
am tatsächlichen Land-Deck-Übergang aufnehmen. Reine Patrouillen ohne planmäßige
Halte sind keine automatisch geeigneten Passagierverbindungen.
Beide Befehle sind auch ohne eingeloggten Bot in der Konsole verfügbar.
Die Spawn-ID bleibt auf beiden Karten dieselbe, obwohl der Core beim
Kartenwechsel eine andere Transportkopie verwendet. Diese kopiert der Recorder
nicht als zweites Fahrzeug in den Graphen.
Mit dem GM auf festem Boden direkt vor der gewünschten Einstiegsstelle stehen:
```text
.clientless node add lift_lower elevator
.clientless transport near 30
```
Für andere Verkehrsmittel den Nodetyp `tram`, `ship` oder `zeppelin` verwenden.
`near` zeigt unter anderem Entry, Spawn-ID, Weltposition, `stopped`, Periode,
Fortschritt und verbleibende Haltezeit. Die **Spawn-ID**, nicht die Entry-ID,
bindet die Aufnahme an das konkrete Fahrzeug bzw. den konkreten Wagen.
```text
.clientless transport inspect 204666
```
In der Konsole wird ein online befindlicher CPC-Player als Beobachter angegeben:
```text
clientless transport inspect 204666 26
clientless transport near 30 26
```
Die vom Transportrecorder verwendeten Node-Koordinaten können auch über die
Konsole geprüft werden:
```text
clientless transport node tram_sw
clientless transport node tram_if
```
Das Objekt muss auf der Map des Beobachters geladen sein. `remainingPause` wird
in Millisekunden ausgegeben; `4294967295` bezeichnet einen Halt ohne bekannte
zeitliche Begrenzung. Ein GoState allein wird nicht als Ankunft interpretiert.
## 2. Geeigneten Stehplatz speichern (optional vorab)
Normal an Bord gehen, einen sicheren, ebenen Platz wählen und dort stehen:
```text
.clientless transport stand add center
.clientless transport stand list
```
Stehplätze sind lokal zum Transportmodell gespeichert und bleiben bei Bewegung
und Kartenwechseln an derselben Stelle des Fahrzeugs. Sie können auch während
der Fahrt aufgenommen werden. Nicht fliegen oder fallen. Derselbe Name wird
nicht still überschrieben. Andere Namen erlauben weitere Standorte.
```text
.clientless transport stand list 4170
.clientless transport stand remove 4170 unused_name
```
`list <entry>` und `remove <entry> <name>` sind konsolenfähig. Ein von einer
Haltestelle verwendeter Standname kann nicht entfernt werden, bevor deren
Aufnahme ausdrücklich entfernt wurde.
## 3. Land -> Deck aufnehmen
Wieder am gemeinsamen Haltestellen-Node auf festem Boden stehen. Auf die nächste
Ankunft warten, damit ein vollständiges Haltefenster verfügbar ist:
```text
.clientless transport record start lift_lower 204666
```
Nun normal zum Fahrzeug gehen, es betreten und bis zum gewünschten Stehplatz
laufen. Die Aufnahme erfolgt automatisch alle 100 ms mit mindestens 0,75 Yards
Punktabstand. Der Wechsel vom Land zum Passenger wird automatisch markiert.
```text
.clientless transport record mark
.clientless transport record info
.clientless transport record stop center
```
`mark` ist optional und erzwingt einen zusätzlichen Punkt. `stop center`
speichert den Übergang und legt bei Bedarf den Stand `center` an. Existiert er
bereits, muss der GM höchstens 1,5 Yards davon entfernt stehen.
Die Aufnahme muss während desselben Halts beendet werden. Abfahrt,
Transportwechsel, Verlassen des Fahrzeugs nach bereits erfolgtem Einstieg,
Positionssprünge über 8 Yards, Flug oder ein Übergang über 60 Yards machen sie
ungültig. Vorhandene Aufnahmen werden nicht still überschrieben.
```text
.clientless transport record cancel
```
Bei einem Fehler bleibt die Diagnose über `record info` sichtbar. Erst mit
`cancel` verwerfen und beim nächsten Halt neu beginnen. Jeder GM hat einen
eigenen Aufnahmebuffer; Logout verwirft nur dessen ungespeicherte Aufnahme.
## 4. Gegenhaltestelle aufnehmen
Mit genau demselben Fahrzeug/Wagen zur Gegenhaltestelle fahren. Aussteigen und
auf festem Boden den gemeinsamen Node anlegen:
```text
.clientless node add lift_upper elevator
```
Bei einer folgenden Ankunft von diesem Node erneut an Bord zum gleichen lokalen
Stand laufen und aufnehmen:
```text
.clientless transport record start lift_upper 204666
.clientless transport record stop center
.clientless transport stops
```
Die Deck-Endpunkte beider Übergänge dürfen höchstens 3 Yards auseinanderliegen.
Damit wird keine unbekannte längere Querung des Decks zwischen zwei verschiedenen
Standorten benötigt. Der aufgezeichnete Übergang wird beim Ausstieg umgekehrt.
Deshalb einen Weg aufnehmen, der in beiden Laufrichtungen begehbar ist.
Bei einer Tiefenbahnaufnahme denselben Wagen an beiden Bahnhöfen verwenden.
Die sechs Wagen haben Entries 176080..176085 und Spawn-IDs 18802..18807.
Die Haltefenster betragen ungefähr 11..14 Sekunden; kurze Rampenwege sind
entsprechend wichtig. Ein anderer Wagen ist eine andere Aufnahmeverbindung.
## 5. Fahrtkante anlegen
```text
.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
Kante. Es darf nur verwendet werden, wenn das reale Verkehrsmittel beide
Richtungen bedient. Ohne `both` entsteht nur die angegebene Fahrtrichtung.
Die Kante wird erst nach Prüfung beider Haltestellen angelegt. Gleicher Entry,
gleicher Spawn, verschiedene Dockpositionen/Maps und kompatible Stehplätze sind
notwendig. Die Typen `tram`/`elevator` gehören zu GameObjects des Typs 11,
`ship`/`zeppelin` zu Typ 15.
Die Haltestellen benötigen wie andere Nodes eine Verbindung zum übrigen Netz:
aufgenommene Groundkanten oder zulässige, vollständig geprüfte MMAP-Anschlüsse.
## 6. Botfahrt testen
Arthoran zunächst an der Abfahrt positionieren und clientlos einloggen. Das
bestehende Login-Lua-Skript kann bereits andere Aufträge starten; für einen
isolierten Transporttest dieses Skript vorher entsprechend einstellen, z.B.:
```lua
queue_action("travel_to_node", {
node = "lift_upper",
hard_destination = true
})
```
Alternativ bei einem untätigen CPC-Player über Konsole oder Spielchat:
```text
clientless transport travel 26 lift_upper
clientless transport status 26
clientless actioninfo 26
```
`travel` hängt einen Auftrag an die reguläre CPC-Aktionswarteschlange an; es
unterbricht keine bereits laufende andere Aktion. Die Zustände sind `Waiting`,
`Boarding`, `Riding`, `Leaving`, `Arrived`, `Failed`. Während der Mitfahrt wird
keine Groundnavigation gestartet. Native Kartenwechsel werden für CPC ohne
Client-ACK abgeschlossen. Der nächste Graphschritt beginnt erst nach dem
Aussteigen und Erreichen des gemeinsamen Zielnodes auf Land.
```text
clientless transport cancel 26
```
Abbruch auf Land stoppt die Fahrt. An Bord bleibt der Bot Passenger und steigt
an einer sicheren aufgezeichneten Haltestelle aus, statt in der Luft oder im
Wasser abgehängt zu werden. Ein harter Timeout meldet `Failed` und lässt eine
noch vorhandene Passenger-Zuordnung bestehen; die Diagnose bleibt erhalten.
Alle Transport-Zustandswechsel und Aufnahmefehler stehen in
`adventurebot-navigation.log`. `.clientless`-Spielchatbefehle und deren Antworten
werden dort ebenfalls protokolliert; Konsolenantworten sind über tmux lesbar.
## Pflege
```text
.clientless transport reload
.clientless transport forget lift_lower
```
`forget` entfernt nur die Übergangsaufnahme. Node, Groundkanten und benannte
Stehplätze bleiben bestehen. Nach Verschieben des Nodes dessen Aufnahme neu
anlegen. Fahrtkanten mit unvollständigen Daten werden nicht geplant.
## Verifikation und Grenzen
Automatisierte Zustands-/Recorder-Tests:
```text
python3 tests/clientless/test_transport.py
```
Diese verwenden die tatsächlichen Produktionsmethoden mit deterministischen
Weltobjekt-Doubles. Sie prüfen unter anderem Haltefenster, Koordinatenrahmen,
Warteverhalten, Passenger-Zuordnung, Ausstieg und Abbruch/Timeout. Sie beweisen
keine Kollisionsfreiheit eines konkreten Decks und keine erfolgreiche Fahrt
auf einem realen GameObject.
Nach dem Tiefenbahntest folgen ein Schiff mit Kartenwechsel und ein Zeppelin;
Fahrstühle werden anschließend behandelt. Je Verbindung beide Fahrtrichtungen,
verpasste Halte, Ein-/Ausstieg und Fortsetzung einer Groundroute prüfen. Logout/Serverneustart
während einer Typ-11-Fahrt ist noch separat zu verifizieren; der bestehende
Trinity-Loginpfad stellt vorrangig die Typ-15-Transportzuordnung wieder her.
Die Recorder setzen einen normalen GM-Client für die echte Bewegung voraus;
sie erzeugen keine synthetischen Deckpfade.
## Live-Test Sturmwind → Eisenschmiede, 26.10.2026
Arthoran (GUID 26) fuhr über Kante 1741 von `tram_sw` nach `tram_if` auf
Wagen 18803. Navigationlog: Boarding 00:37:34, Riding mit `passenger=1`
00:37:35, Leaving 00:38:38, Arrived mit `passenger=0` 00:38:42.
Die Konsole meldete anschließend `travel-route-complete`. Das belegt die
serverseitige Durchführung einer Richtung; Gegenrichtung und visuelle
Ingame-Abnahme sind damit noch nicht bestätigt.
Vorbedingung/noch offener Fehler: Beim ersten Start ausschließlich an der
Sturmwindseite war Spawn 18803 nicht geladen. Seine Datenbank-Spawnposition
liegt auf der Eisenschmiedeseite (4.528, 8.435, 7.011). Ein kurzer Diagnose-Login
Arthorans an `tram_if` lud das Spawn-Grid; danach war der aktive Wagen auch von
Sturmwind aus verfügbar. Für den eigentlichen Fahrtest wurde Arthoran offline
wieder an `tram_sw` positioniert. Während der Fahrt wurden keine Positionen per
Datenbank oder Teleport verändert. Automatisches Laden des entfernten
Transport-Spawn-Grids muss vor autonomem Betrieb ergänzt werden; der erfolgreiche
Fahrtest nach manuellem Vorladen behebt diese Einschränkung nicht.
Testskript und vorherige Charakterposition gesichert unter
`/tmp/vcore-tram-ride.1i65g2dm`.
Nachtrag: Der oben beschriebene Spawn-Grid-Fehler wurde anschließend im
allgemeinen Map-Lifecycle behoben und mit einem frischen Prozess ausschließlich
von Sturmwind aus geprüft. Alle sechs Wagen waren verfügbar und bewegten sich.
Details und verbleibende Befunde: [transport-grid-lifecycle.md](transport-grid-lifecycle.md).
## Nächste Verbindungen: Schiff und Zeppelin
Aktuelle Nutzerauswahl: zuerst **Schiff ab Sturmwind**. Die konkrete Aufnahme
für Spawn 2 und der Testablauf stehen in
[ship-stormwind-ruttheran.md](ship-stormwind-ruttheran.md).
Geeignete erste Kandidaten aus der Testdatenbank:
- Spawn 7, Entry 20808, `The Maiden's Fancy`: Küstenschiff; tatsächliche Häfen
über `services 7` prüfen. Der Kartenwechsel zwischen Kalimdor und den östlichen
Königreichen prüft die Passenger-Übergabe zwischen Transportkopien.
- Spawn 20, Entry 190549, `The Zephyr`: Zeppelin auf Kalimdor. Die vorhandene
Transport-/SmartAI bleibt erhalten. Die Fahrt ergänzt den Lufttransport-Test
ohne Kartenwechsel.
Die Recorder- und Link-Befehle bleiben dieselben. Beispiele mit nach Prüfung
der Anlegestellen manuell aufgenommenen Nodes:
```text
.clientless node add ship_ratchet ship
.clientless transport record start ship_ratchet 7
.clientless transport record stop maiden_center
.clientless node add ship_booty_bay ship
.clientless transport record start ship_booty_bay 7
.clientless transport record stop maiden_center
.clientless transport link ship_ratchet ship_booty_bay ship 100 both
```
Den ersten Stehplatz an der Gegenhaltestelle wieder exakt aufsuchen. Zwischen
den beiden Node-Aufnahmen mit demselben Schiff mitfahren. `record start` beginnt
jeweils auf festem Boden, `record stop` endet an Bord während desselben Halts.
Pro physischer Anlegestelle weiterhin nur einen gemeinsamen Node verwenden;
Landverbindungen zum übrigen TravelGraph getrennt als Groundkante erfassen.
Für den Zeppelin analog eigene Haltestellen-Keys, Fahrzeug-Spawn 20 und
Kantentyp `zeppelin` verwenden. Schiffs- und Zeppelin-Verbindungen erst nach
einer echten Fahrt als verifiziert betrachten. Die automatisierten Tests
simulieren den nativen Kartenwechsel; sie ersetzen keinen echten Worldport-
und Kollisions-/Sichtbarkeitstest.
+2
View File
@@ -1 +1,3 @@
*.sql
!2026_10_25_00_clientless_transport.sql
!2026_10_26_00_clientless_disable_taxi_585.sql
@@ -0,0 +1,27 @@
-- Vcore CPC: one shared embark/disembark node per physical stop.
-- Apply explicitly to the dedicated test world database. No routes are seeded.
CREATE TABLE IF NOT EXISTS vcore_clientless_transport_stop (
node_key varchar(100) NOT NULL,
transport_entry int unsigned NOT NULL,
transport_spawn int unsigned NOT NULL,
map int unsigned NOT NULL,
dock_x float NOT NULL, dock_y float NOT NULL, dock_z float NOT NULL, dock_o float NOT NULL,
period_ms int unsigned NOT NULL,
stand_name varchar(100) NOT NULL,
PRIMARY KEY (node_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS vcore_clientless_transport_point (
node_key varchar(100) NOT NULL,
point_index int unsigned NOT NULL,
on_transport tinyint unsigned NOT NULL,
x float NOT NULL, y float NOT NULL, z float NOT NULL, o float NOT NULL,
PRIMARY KEY (node_key,point_index)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS vcore_clientless_transport_stand (
transport_entry int unsigned NOT NULL,
name varchar(100) NOT NULL,
x float NOT NULL, y float NOT NULL, z float NOT NULL, o float NOT NULL,
PRIMARY KEY (transport_entry,name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -0,0 +1,11 @@
-- Explicit test-TravelGraph correction. No changes to WoW taxi definitions/NPCs.
START TRANSACTION;
UPDATE vcore_clientless_travel_node
SET enabled=0,comment='Disabled: taxi 585 is not a regular player flight point (GM verified in Ruttheran)'
WHERE node_key='taxi_585';
UPDATE vcore_clientless_world_point
SET enabled=0,comment='Disabled: taxi 585 is not a regular player flight point (GM verified in Ruttheran)'
WHERE point_key='taxi_585';
UPDATE vcore_clientless_travel_edge SET enabled=0
WHERE from_node_key='taxi_585' OR to_node_key='taxi_585';
COMMIT;
+16
View File
@@ -198,6 +198,9 @@ void ChatHandler::SendSysMessage(const char *str, bool escapeCharacters)
while (char* line = LineFromMessage(pos))
{
if (_logClientlessCommand)
TC_LOG_INFO("server.adventurebot.navigation",
"CLIENTLESS_COMMAND_REPLY account=%u text='%s'", m_session->GetAccountId(), line);
BuildChatPacket(data, CHAT_MSG_SYSTEM, LANG_UNIVERSAL, nullptr, nullptr, line);
m_session->SendPacket(&data);
}
@@ -411,6 +414,19 @@ bool ChatHandler::SetDataForCommandInTable(std::vector<ChatCommand>& table, char
bool ChatHandler::_ParseCommands(char const* text)
{
// Scope reply mirroring to this command invocation, including syntax errors.
std::string const command(text);
std::string const token = command.substr(0, command.find(' '));
_logClientlessCommand = m_session && !token.empty() &&
hasStringAbbr("clientless", token.c_str());
struct ResetClientlessLog
{
bool& Enabled;
~ResetClientlessLog() { Enabled = false; }
} reset{ _logClientlessCommand };
if (_logClientlessCommand)
TC_LOG_INFO("server.adventurebot.navigation",
"CLIENTLESS_COMMAND account=%u text='%s'", m_session->GetAccountId(), text);
if (ExecuteCommandInTable(getCommandTable(), text, text))
return true;
+1
View File
@@ -147,6 +147,7 @@ class TC_GAME_API ChatHandler
// common global flag
bool sentErrorMessage;
bool _logClientlessCommand = false;
};
class TC_GAME_API CliHandler : public ChatHandler
@@ -49,6 +49,7 @@
#include <G3D/Box.h>
#include <G3D/CoordinateFrame.h>
#include <G3D/Quat.h>
#include <limits>
bool QuaternionData::isUnit() const
{
@@ -330,6 +331,54 @@ public:
}
}
bool GetTransportDockState(TransportDockState& state) const override
{
state = {};
if (!_animationInfo || !_animationInfo->TotalTime)
return false;
state.Progress = _pathProgress;
state.Period = _animationInfo->TotalTime;
if (!_stopFrames.empty())
{
if (int32(GameTime::GetGameTimeMS() - _owner.GetUInt32Value(GAMEOBJECT_LEVEL)) >= 0)
{
state.Stopped = true;
state.RemainingPause = std::numeric_limits<uint32>::max();
}
return true;
}
auto current = _animationInfo->Path.upper_bound(_pathProgress);
if (current == _animationInfo->Path.begin())
return true;
--current;
auto next = std::next(current);
// Consecutive equal animation positions represent an actual dwell,
// unlike a GO state which may already denote a future destination.
auto samePosition = [](TransportAnimationEntry const* a, TransportAnimationEntry const* b)
{
return std::fabs(a->Pos.X - b->Pos.X) < 0.001f &&
std::fabs(a->Pos.Y - b->Pos.Y) < 0.001f && std::fabs(a->Pos.Z - b->Pos.Z) < 0.001f;
};
while (next != _animationInfo->Path.end() && samePosition(current->second, next->second))
++next;
uint32 end = next == std::next(current) ? _pathProgress :
std::prev(next)->first;
// A stationary origin with a rotating deck is not a boarding stop.
auto rotation = _animationInfo->Rotations.upper_bound(_pathProgress);
if (rotation != _animationInfo->Rotations.begin() && rotation != _animationInfo->Rotations.end())
{
auto before = std::prev(rotation)->second;
auto after = rotation->second;
if (std::fabs(before->X - after->X) + std::fabs(before->Y - after->Y) +
std::fabs(before->Z - after->Z) + std::fabs(before->W - after->W) > 0.001f)
return true;
end = std::min(end, rotation->first);
}
state.Stopped = end > _pathProgress;
state.RemainingPause = state.Stopped ? end - _pathProgress : 0;
return true;
}
uint32 GetTransportPeriod() const
{
if (_animationInfo)
@@ -2963,6 +3012,14 @@ void GameObject::BuildValuesUpdate(uint8 updateType, ByteBuffer* data, Player* t
data->append(fieldBuffer);
}
bool GameObject::GetTransportDockState(TransportDockState& state) const
{
state = {};
if (Transport const* transport = ToTransport())
return transport->GetDockState(state);
return m_goTypeImpl && m_goTypeImpl->GetTransportDockState(state);
}
std::vector<uint32> const* GameObject::GetPauseTimes() const
{
if (GameObjectType::Transport const* transport = dynamic_cast<GameObjectType::Transport const*>(m_goTypeImpl.get()))
@@ -33,6 +33,13 @@ class OPvPCapturePoint;
class SpellInfo;
class Transport;
class TransportBase;
struct TransportDockState
{
uint32 Progress = 0;
uint32 Period = 0;
uint32 RemainingPause = 0;
bool Stopped = false;
};
class Unit;
struct TransportAnimation;
enum SpellTargetCheckTypes : uint8;
@@ -55,6 +62,7 @@ public:
virtual void Update([[maybe_unused]] uint32 diff) { }
virtual void OnStateChanged([[maybe_unused]] GOState oldState, [[maybe_unused]] GOState newState) { }
virtual void OnRelocated() { }
virtual bool GetTransportDockState(TransportDockState&) const { return false; }
protected:
GameObject& _owner;
@@ -206,6 +214,7 @@ class TC_GAME_API GameObject : public WorldObject, public GridObject<GameObject>
static void SetGoArtKit(uint8 artkit, GameObject* go, ObjectGuid::LowType lowguid = 0);
std::vector<uint32> const* GetPauseTimes() const;
bool GetTransportDockState(TransportDockState& state) const;
void SetPathProgressForClient(float progress);
void PlayAnimKit(int32 animKit);
@@ -273,6 +273,29 @@ void Transport::Update(uint32 diff)
}
}
bool Transport::GetDockState(TransportDockState& state) const
{
state = {};
if (!_transportInfo || !GetTransportPeriod())
return false;
state.Period = GetTransportPeriod();
state.Progress = _pathProgress % state.Period;
TransportMovementState movement;
size_t index;
if (!_transportInfo->ComputePosition(state.Progress, &movement, &index))
return false;
if (movement == TransportMovementState::WaitingOnPauseWaypoint)
for (TransportPathSegment const& segment : _transportInfo->PathLegs[index].Segments)
if (state.Progress >= segment.SegmentEndArrivalTimestamp &&
state.Progress < segment.SegmentEndArrivalTimestamp + segment.Delay)
{
state.Stopped = true;
state.RemainingPause = segment.SegmentEndArrivalTimestamp + segment.Delay - state.Progress;
break;
}
return true;
}
void Transport::AddPassenger(WorldObject* passenger)
{
if (!IsInWorld())
@@ -90,6 +90,7 @@ class TC_GAME_API Transport : public GameObject, public TransportBase
uint32 GetTransportPeriod() const { return GetUInt32Value(GAMEOBJECT_LEVEL); }
void SetPeriod(uint32 period) { SetUInt32Value(GAMEOBJECT_LEVEL, period); }
uint32 GetTimer() const { return _pathProgress; }
bool GetDockState(TransportDockState& state) const;
void UpdatePosition(float x, float y, float z, float o);
+7
View File
@@ -2383,6 +2383,7 @@ void ObjectMgr::RemoveCreatureFromGrid(ObjectGuid::LowType guid, CreatureData co
void ObjectMgr::LoadGameObjects()
{
uint32 oldMSTime = getMSTime();
_transportGameObjectSpawns.clear();
// 0 1 2 3 4 5 6
QueryResult result = WorldDatabase.Query("SELECT gameobject.guid, id, map, position_x, position_y, position_z, orientation, "
@@ -2595,7 +2596,13 @@ void ObjectMgr::LoadGameObjects()
}
if (gameEvent == 0) // if not this is to be managed by GameEvent System or Pool system
{
AddGameobjectToGrid(guid, &data);
// These objects become active only after their spawn grid is loaded.
// Index them so a distant station cannot leave half a service absent.
if (gInfo->type == GAMEOBJECT_TYPE_TRANSPORT)
_transportGameObjectSpawns[data.mapId].push_back(guid);
}
}
while (result->NextRow());
+6
View File
@@ -1392,6 +1392,11 @@ class TC_GAME_API ObjectMgr
return &itr->second;
}
GameObjectDataContainer const& GetAllGameObjectData() const { return _gameObjectDataStore; }
std::vector<ObjectGuid::LowType> const* GetTransportGameObjectSpawns(uint32 mapId) const
{
auto itr = _transportGameObjectSpawns.find(mapId);
return itr != _transportGameObjectSpawns.end() ? &itr->second : nullptr;
}
GameObjectData const* GetGameObjectData(ObjectGuid::LowType spawnId) const
{
GameObjectDataContainer::const_iterator itr = _gameObjectDataStore.find(spawnId);
@@ -1752,6 +1757,7 @@ class TC_GAME_API ObjectMgr
LinkedRespawnContainer _linkedRespawnStore;
CreatureLocaleContainer _creatureLocaleStore;
GameObjectDataContainer _gameObjectDataStore;
std::unordered_map<uint32, std::vector<ObjectGuid::LowType>> _transportGameObjectSpawns;
GameObjectLocaleContainer _gameObjectLocaleStore;
GameObjectTemplateContainer _gameObjectTemplateStore;
GameObjectTemplateAddonContainer _gameObjectTemplateAddonStore;
@@ -244,6 +244,21 @@ void WorldSession::HandleMoveTeleportAck(WorldPackets::Movement::MoveTeleportAck
return;
CompleteNearTeleport(plMover);
}
void WorldSession::CompleteClientlessTeleport()
{
if (!IsClientless() || !GetPlayer())
return;
if (GetPlayer()->IsBeingTeleportedFar())
HandleMoveWorldportAck();
else if (GetPlayer()->IsBeingTeleportedNear())
CompleteNearTeleport(GetPlayer());
}
void WorldSession::CompleteNearTeleport(Player* plMover)
{
plMover->SetSemaphoreTeleportNear(false);
uint32 old_zone = plMover->GetZoneId();
+21
View File
@@ -116,6 +116,27 @@ void Map::LoadAllCells()
LoadGrid((cellX + 0.5f - CENTER_GRID_CELL_ID) * SIZE_OF_GRID_CELL, (cellY + 0.5f - CENTER_GRID_CELL_ID) * SIZE_OF_GRID_CELL);
}
void Map::LoadTransportGameObjectGrids()
{
auto spawns = sObjectMgr->GetTransportGameObjectSpawns(GetId());
if (!spawns)
return;
uint32 grids = 0;
for (ObjectGuid::LowType spawnId : *spawns)
{
GameObjectData const* data = sObjectMgr->GetGameObjectData(spawnId);
if (!data || data->mapId != GetId() || !(data->spawnMask & (1u << GetSpawnMode())))
continue;
// Use the normal loader: respawns, pools, phases and spawn groups retain
// their existing rules. Loading an already loaded grid is idempotent.
if (EnsureGridLoaded(Cell(data->spawnPoint.GetPositionX(), data->spawnPoint.GetPositionY())))
++grids;
}
TC_LOG_INFO("server.loading", "Initialized transport GameObject grids: map=%u instance=%u grids=%u candidates=%u",
GetId(), GetInstanceId(), grids, uint32(spawns->size()));
}
void Map::InitStateMachine()
{
si_GridStates[GRID_STATE_INVALID] = new InvalidState();
+1
View File
@@ -218,6 +218,7 @@ class TC_GAME_API Map : public GridRefManager<NGridType>
void SetUnloadLock(GridCoord const& p, bool on) { getNGrid(p.x_coord, p.y_coord)->setUnloadExplicitLock(on); }
void LoadGrid(float x, float y);
void LoadAllCells();
void LoadTransportGameObjectGrids();
bool UnloadGrid(NGridType& ngrid, bool pForce);
void GridMarkNoUnload(uint32 x, uint32 y);
void GridUnmarkNoUnload(uint32 x, uint32 y);
+5
View File
@@ -74,6 +74,8 @@ Map* MapManager::CreateWorldMap(uint32 mapId, uint32 instanceId)
map->LoadRespawnTimes();
map->LoadCorpseData();
map->LoadTransportGameObjectGrids();
if (sWorld->getBoolConfig(CONFIG_BASEMAP_LOAD_GRIDS))
map->LoadAllCells();
@@ -104,6 +106,8 @@ InstanceMap* MapManager::CreateInstance(uint32 mapId, uint32 instanceId, Instanc
bool load_data = save != nullptr;
map->CreateInstanceData(load_data);
map->LoadTransportGameObjectGrids();
if (sWorld->getBoolConfig(CONFIG_INSTANCEMAP_LOAD_GRIDS))
map->LoadAllCells();
@@ -118,6 +122,7 @@ BattlegroundMap* MapManager::CreateBattleground(uint32 mapId, uint32 instanceId,
ASSERT(map->IsBattlegroundOrArena());
map->SetBG(bg);
bg->SetBgMap(map);
map->LoadTransportGameObjectGrids();
return map;
}
+2
View File
@@ -743,6 +743,7 @@ class TC_GAME_API WorldSession
void HandleForceSpeedChangeAck(WorldPacket& recvData);
void HandleMoveKnockBackAck(WorldPacket& recvPacket);
void HandleMoveTeleportAck(WorldPackets::Movement::MoveTeleportAck& packet);
void CompleteClientlessTeleport();
void HandleMoveWaterWalkAck(WorldPacket& recvPacket);
void HandleFeatherFallAck(WorldPacket& recvData);
void HandleMoveHoverAck(WorldPacket& recvData);
@@ -1318,6 +1319,7 @@ class TC_GAME_API WorldSession
void SendLoadCUFProfiles();
private:
void CompleteNearTeleport(Player* playerMover);
void ProcessQueryCallbacks();
QueryCallbackProcessor _queryProcessor;
@@ -1,4 +1,5 @@
#include "ClientlessActionExecutorMgr.h"
#include "ClientlessTransportMgr.h"
#include "ClientlessPerceptionMgr.h"
#include "ClientlessPlayerMgr.h"
@@ -149,6 +150,12 @@ ClientlessActionExecutorMgr* ClientlessActionExecutorMgr::instance()
return &instance;
}
void ClientlessActionExecutorMgr::Clear(uint32 characterGuid)
{
_records.erase(characterGuid);
sClientlessTravelMgr->CancelTravel(characterGuid);
}
char const* ClientlessActionExecutorMgr::StateName(ClientlessActionExecutionState state)
{
switch (state)
@@ -171,8 +178,16 @@ ClientlessActionExecutionStatus const* ClientlessActionExecutorMgr::GetStatus(ui
bool ClientlessActionExecutorMgr::StartAction(Player* player, ClientlessAction const& action, Record& record)
{
record.TransportActive = false;
record.Status = {};
record.Status.ActionName = action.Name;
if (player->GetTransport())
{
record.Status.State = ClientlessActionExecutionState::Failed;
record.Status.Detail = "player is already aboard a transport; wait for a safe recorded stop";
record.Active = false;
return false;
}
record.Status.State = ClientlessActionExecutionState::SelectingTarget;
record.FinalLocalLegPending = false;
record.FinalLocalLegActive = false;
@@ -722,6 +737,7 @@ bool ClientlessActionExecutorMgr::StartTravelToLocation(
std::string anchorNode;
std::string error;
float proxyDistance = std::numeric_limits<float>::infinity();
sClientlessTravelGraphMgr->EnsureLoaded();
if (!sClientlessTravelGraphMgr->FindNearestNodeToPosition(
point->MapId, point->X, point->Y, point->Z, anchorNode, proxyDistance))
{
@@ -796,15 +812,11 @@ bool ClientlessActionExecutorMgr::StartFinalLocalLeg(
record.FinalZ,
record.FinalO);
sClientlessLuaEngine->DismountGroundMount(player, "final-local-leg");
if (!sClientlessTravelMgr->StartTravel(player, destination, false))
// A graph boundary does not require dismounting. Keep the normal ground
// mount policy on the final MMAP leg, including subsequent local segments.
if (!sClientlessTravelMgr->StartTravel(player, destination, true))
{
record.Status.State = ClientlessActionExecutionState::Failed;
record.Status.Detail = "location final local travel start failed";
record.Active = false;
record.FinalLocalLegPending = false;
return false;
return RecoverFinalLocalLeg(player, characterGuid, record, "final-local-start-failed");
}
record.FinalLocalLegPending = false;
@@ -816,11 +828,11 @@ bool ClientlessActionExecutorMgr::StartFinalLocalLeg(
record.Status.Detail = "local navigation to final location";
record.Active = true;
TC_LOG_INFO("server.adventurebot",
"[AdventureBot] travel-location-local-leg guid=%u name='%s' location='%s'",
TC_LOG_INFO("server.adventurebot.navigation",
"[AdventureBot] travel-location-local-leg guid=%u name='%s' location='%s' mounted=%u",
characterGuid,
player->GetName().c_str(),
record.FinalLocationId.c_str());
record.FinalLocationId.c_str(), player->IsMounted() ? 1u : 0u);
return true;
}
@@ -878,6 +890,22 @@ bool ClientlessActionExecutorMgr::StartRouteStep(
step.NodeKey.c_str(), step.NodeType.c_str(), step.EdgeType.c_str(),
step.EdgeDatabaseId, step.EdgeReverse ? 1u : 0u, record.RecoveryCount);
if (ClientlessTransportMgr::IsTransportEdge(step.EdgeType))
{
std::string error;
if (!record.RouteStep || !sClientlessTransportMgr->Start(player,
record.Route[record.RouteStep - 1].NodeKey, step.NodeKey, error))
{
return RecoverTravel(player, characterGuid, record, step.EdgeDatabaseId,
"transport-start-failed: " + error);
}
record.TransportActive = true;
record.Active = true;
record.Status.State = ClientlessActionExecutionState::Moving;
record.Status.Detail = "waiting for native transport";
return true;
}
if (step.EdgeType != "start" && step.EdgeType != "ground" &&
step.EdgeType != "taxi" && step.EdgeType != "portal" &&
step.EdgeType != "local_bridge")
@@ -990,18 +1018,6 @@ bool ClientlessActionExecutorMgr::StartRouteStep(
return false;
}
// C8.13a only imports far map transitions. Keeping this invariant here
// prevents a clientless Player from getting stuck waiting for the near
// MOVE_TELEPORT_ACK path, which is intentionally not implemented yet.
if (uint32(atEntry->ContinentID) == teleport->target_mapId)
{
record.Status.State = ClientlessActionExecutionState::Failed;
record.Status.TargetName = "node:" + step.NodeKey;
record.Status.Detail = "same-map portal transition is not supported by C8.13a";
record.Active = false;
return false;
}
if (player->GetMapId() != uint32(atEntry->ContinentID) ||
!player->IsInAreaTriggerRadius(atEntry))
{
@@ -1045,7 +1061,7 @@ bool ClientlessActionExecutorMgr::StartRouteStep(
areaTriggerPacket << triggerId;
player->GetSession()->HandleAreaTriggerOpcode(areaTriggerPacket);
if (player->IsBeingTeleportedFar())
if (player->IsBeingTeleported())
{
if (!player->GetSession()->IsClientless())
{
@@ -1055,13 +1071,11 @@ bool ClientlessActionExecutorMgr::StartRouteStep(
return false;
}
// A real client would answer the far transfer with CMSG_MOVE_WORLDPORT_ACK.
// Clientless sessions have no socket, so finish the exact same server-side
// transfer path explicitly after the normal AreaTrigger handler accepted it.
player->GetSession()->HandleMoveWorldportAck();
// Finish the normal near/far ACK after the real AreaTrigger handler.
player->GetSession()->CompleteClientlessTeleport();
}
if (!player->IsInWorld() || player->GetMapId() != teleport->target_mapId)
if (!player->IsInWorld() || player->IsBeingTeleported() || player->GetMapId() != teleport->target_mapId)
{
TC_LOG_WARN("server.adventurebot.navigation",
"EDGE_FAILED guid=%u name='%s' kind='portal-transfer-not-completed' edgeDbId=%u "
@@ -1083,6 +1097,18 @@ bool ClientlessActionExecutorMgr::StartRouteStep(
float const dy = player->GetPositionY() - teleport->target_Y;
float const dz = player->GetPositionZ() - teleport->target_Z;
float const arrivalError = std::sqrt(dx * dx + dy * dy + dz * dz);
// On a same-map transition the map ID alone also matches a denied
// teleport. Require arrival at the actual destination before advancing.
if (arrivalError > 5.0f)
{
TC_LOG_WARN("server.adventurebot.navigation",
"EDGE_FAILED guid=%u kind='portal-destination-not-reached' trigger=%u arrivalError=%.2f",
characterGuid, triggerId, arrivalError);
record.Status.State = ClientlessActionExecutionState::Failed;
record.Status.Detail = "AreaTrigger destination not reached";
record.Active = false;
return false;
}
record.Status.State = ClientlessActionExecutionState::Moving;
record.Status.TargetName = "node:" + step.NodeKey;
@@ -1649,6 +1675,7 @@ bool ClientlessActionExecutorMgr::RecoverTravel(
{
std::string anchorNode;
float proxyDistance = std::numeric_limits<float>::infinity();
sClientlessTravelGraphMgr->EnsureLoaded();
if (sClientlessTravelGraphMgr->FindNearestNodeToPosition(
record.FinalMapId, record.FinalX, record.FinalY, record.FinalZ,
anchorNode, proxyDistance))
@@ -1738,6 +1765,36 @@ void ClientlessActionExecutorMgr::UpdateTravelToNode(
uint32 characterGuid,
Record& record)
{
if (record.TransportActive)
{
ClientlessTransportStatus const* transport = sClientlessTransportMgr->GetStatus(characterGuid);
if (transport && transport->Phase != ClientlessTransportPhase::Failed &&
transport->Phase != ClientlessTransportPhase::Arrived)
{
record.Status.Detail = transport->Detail;
return;
}
record.TransportActive = false;
if (transport && transport->Phase == ClientlessTransportPhase::Arrived)
{
++record.RouteStep;
StartRouteStep(player, characterGuid, record);
}
else if (player->GetTransport() || (transport && transport->Cancelled))
{
record.Status.State = ClientlessActionExecutionState::Failed;
record.Status.Detail = transport ? transport->Detail : "transport state lost aboard";
record.Active = false;
}
else
{
uint32 edge = record.RouteStep < record.Route.size() ? record.Route[record.RouteStep].EdgeDatabaseId : 0;
std::string reason = transport ? transport->Detail : "transport state lost";
RecoverTravel(player, characterGuid, record, edge, "transport-failed: " + reason);
}
return;
}
if (record.RecoveryWanderActive)
{
ClientlessTravelStatus const* wander =
@@ -2065,11 +2122,11 @@ void ClientlessActionExecutorMgr::UpdateTravelToLocation(
record.Active = false;
record.FinalLocalLegActive = false;
TC_LOG_INFO("server.adventurebot",
"[AdventureBot] travel-location-complete guid=%u name='%s' location='%s'",
TC_LOG_INFO("server.adventurebot.navigation",
"[AdventureBot] travel-location-complete guid=%u name='%s' location='%s' mounted=%u",
characterGuid,
player->GetName().c_str(),
record.FinalLocationId.c_str());
record.FinalLocationId.c_str(), player->IsMounted() ? 1u : 0u);
return;
}
@@ -2083,6 +2140,12 @@ void ClientlessActionExecutorMgr::UpdateTravelToLocation(
ClientlessTravelMgr::StateName(travel->State),
player->GetPositionX(), player->GetPositionY(), player->GetPositionZ());
RecoverFinalLocalLeg(player, characterGuid, record, reason);
}
bool ClientlessActionExecutorMgr::RecoverFinalLocalLeg(
Player* player, uint32 characterGuid, Record& record, std::string const& reason)
{
record.FinalLocalLegActive = false;
record.FinalLocalLegPending = true;
if (!record.Route.empty())
@@ -2097,8 +2160,23 @@ void ClientlessActionExecutorMgr::UpdateTravelToLocation(
"C820_EXIT_BLOCKED edgeDbId=%u endIndex=%d reason='%s'",
last.EdgeDatabaseId, last.GroundEndIndex, reason.c_str());
}
else
{
record.FailedBridgeKeys.insert("finish:" + last.NodeKey);
TC_LOG_WARN("server.adventurebot.navigation",
"C821_NODE_FINISH_BLOCKED guid=%u node='%s' reason='%s'",
characterGuid, last.NodeKey.c_str(), reason.c_str());
}
}
RecoverTravel(player, characterGuid, record, 0, reason);
else
{
// A direct local attempt has no graph endpoint to blacklist. Ensure
// recovery can select the graph and then reject failed endpoints.
TC_LOG_WARN("server.adventurebot.navigation",
"C821_DIRECT_LOCAL_FAILED guid=%u location='%s' reason='%s'",
characterGuid, record.FinalLocationId.c_str(), reason.c_str());
}
return RecoverTravel(player, characterGuid, record, 0, reason);
}
void ClientlessActionExecutorMgr::UpdateActive(
@@ -2158,7 +2236,8 @@ void ClientlessActionExecutorMgr::Update(uint32 /*diff*/)
{
for (auto const& [characterGuid, session] : sWorld->GetAllClientlessSessions())
{
if (!session || !sClientlessPlayerMgr->IsManaged(characterGuid))
if (!session || !sClientlessPlayerMgr->IsManaged(characterGuid) ||
sWorld->GetClientlessPlayerState(characterGuid) != ClientlessPlayerState::Online)
continue;
Player* player = session->GetPlayer();
@@ -40,6 +40,7 @@ public:
static ClientlessActionExecutorMgr* instance();
void Update(uint32 diff);
void Clear(uint32 characterGuid);
ClientlessActionExecutionStatus const* GetStatus(uint32 characterGuid) const;
static char const* StateName(ClientlessActionExecutionState state);
@@ -58,6 +59,7 @@ private:
std::vector<ClientlessTravelEdgePoint> EdgePath;
// C8.10b: active server-side taxi edge.
bool TransportActive = false;
bool TaxiActive = false;
uint32 TaxiPathId = 0;
uint32 TaxiSourceNodeId = 0;
@@ -98,6 +100,7 @@ private:
bool StartTravelToNode(Player* player, ClientlessAction const& action, Record& record);
bool StartTravelToLocation(Player* player, ClientlessAction const& action, Record& record);
bool StartFinalLocalLeg(Player* player, uint32 characterGuid, Record& record);
bool RecoverFinalLocalLeg(Player* player, uint32 characterGuid, Record& record, std::string const& reason);
bool StartRouteStep(Player* player, uint32 characterGuid, Record& record);
bool StartRecordedEdgePath(Player* player, uint32 characterGuid, Record& record);
bool RecoverTravel(Player* player, uint32 characterGuid, Record& record, uint32 failedEdgeDatabaseId, std::string const& reason);
+96 -1
View File
@@ -1,5 +1,8 @@
#include "ClientlessLuaEngine.h"
#include "ClientlessPlayerMgr.h"
#include "ClientlessActionExecutorMgr.h"
#include "ClientlessTravelMgr.h"
#include "Config.h"
#include "Log.h"
#include "Player.h"
#include "Random.h"
@@ -14,6 +17,15 @@
namespace
{
// World-thread only. Script effects are committed only after a successful reload.
struct ReloadEffects
{
std::vector<ClientlessAction> Actions;
ClientlessIntent Intent;
bool IntentChanged = false;
};
ReloadEffects* pendingReload = nullptr;
uint32 BoundGuid(lua_State* L) { return uint32(lua_tointeger(L, lua_upvalueindex(1))); }
std::unordered_map<std::string, std::string> ReadMap(lua_State* L, int index)
@@ -48,12 +60,30 @@ int LuaIntend(lua_State* L)
ClientlessIntent intent;
intent.Name = luaL_checkstring(L, 1);
if (lua_gettop(L) >= 2) intent.Parameters = ReadMap(L, 2);
if (pendingReload)
{
bool valid = !intent.Empty();
if (valid)
{
pendingReload->Intent = std::move(intent);
pendingReload->IntentChanged = true;
}
lua_pushboolean(L, valid);
return 1;
}
lua_pushboolean(L, sClientlessPlayerMgr->SetIntent(BoundGuid(L), std::move(intent)));
return 1;
}
int LuaClearIntent(lua_State* L)
{
if (pendingReload)
{
pendingReload->Intent = ClientlessIntent();
pendingReload->IntentChanged = true;
lua_pushboolean(L, true);
return 1;
}
lua_pushboolean(L, sClientlessPlayerMgr->ClearIntent(BoundGuid(L)));
return 1;
}
@@ -63,6 +93,13 @@ int LuaQueueAction(lua_State* L)
ClientlessAction action;
action.Name = luaL_checkstring(L, 1);
if (lua_gettop(L) >= 2) action.Parameters = ReadMap(L, 2);
if (pendingReload)
{
bool valid = !action.Name.empty();
if (valid) pendingReload->Actions.push_back(std::move(action));
lua_pushboolean(L, valid);
return 1;
}
lua_pushboolean(L, sClientlessPlayerMgr->QueueAction(BoundGuid(L), std::move(action)));
return 1;
}
@@ -184,7 +221,10 @@ ClientlessLuaEngine* ClientlessLuaEngine::instance()
ClientlessLuaEngine::ClientlessLuaEngine()
{
_scriptRoot = std::getenv("VCORE_CLIENTLESS_SCRIPT_DIR") ? std::getenv("VCORE_CLIENTLESS_SCRIPT_DIR") : "scripts/clientless";
_scriptRoot = sConfigMgr->GetStringDefault("Clientless.ScriptRoot", "scripts/clientless");
if (char const* overrideRoot = std::getenv("VCORE_CLIENTLESS_SCRIPT_DIR"))
if (*overrideRoot) _scriptRoot = overrideRoot;
_scriptRoot = std::filesystem::absolute(_scriptRoot).lexically_normal().string();
_state = luaL_newstate();
if (!_state)
{
@@ -445,6 +485,61 @@ bool ClientlessLuaEngine::ExecuteAdventureScript(Player* player)
return ExecuteScript(player, (root / "adventure" / "generic.lua").string(), "generic adventure");
}
bool ClientlessLuaEngine::ReloadAdventureScript(Player* player, std::string& detail)
{
if (!player || !player->IsInWorld() || !sClientlessPlayerMgr->IsManaged(player->GetGUID().GetCounter()))
{
detail = "Reload requires an online managed clientless player.";
return false;
}
uint32 guid = player->GetGUID().GetCounter();
auto action = sClientlessActionExecutorMgr->GetStatus(guid);
auto travel = sClientlessTravelMgr->GetStatus(guid);
if (pendingReload || sClientlessPlayerMgr->GetQueuedActionCount(guid) ||
(action && (action->State == ClientlessActionExecutionState::SelectingTarget ||
action->State == ClientlessActionExecutionState::Moving)) ||
(travel && travel->State == ClientlessTravelState::Moving) ||
player->GetTransport() || player->IsInFlight() || player->IsBeingTeleported())
{
detail = "Reload refused: bot has queued actions, is travelling or is aboard a transport. Wait until idle.";
return false;
}
auto previous = _profiles.find(guid);
bool hadProfile = previous != _profiles.end();
ClientlessAdventureProfile backup = hadProfile ? previous->second : ClientlessAdventureProfile();
ReloadEffects effects;
bool success;
{
struct Capture
{
explicit Capture(ReloadEffects& effects) { pendingReload = &effects; }
~Capture() { pendingReload = nullptr; }
} capture(effects);
success = LoadAdventureProfile(player) && ExecuteAdventureScript(player);
}
if (!success)
{
if (hadProfile) _profiles[guid] = std::move(backup);
else _profiles.erase(guid);
detail = "Lua reload failed. Previous profile retained; no new actions or intent applied. See server log.";
}
else
{
if (effects.IntentChanged)
{
if (effects.Intent.Empty()) sClientlessPlayerMgr->ClearIntent(guid);
else sClientlessPlayerMgr->SetIntent(guid, std::move(effects.Intent));
}
for (auto& queued : effects.Actions)
sClientlessPlayerMgr->QueueAction(guid, std::move(queued));
detail = "Profile and adventure script reloaded; queued actions=" + std::to_string(effects.Actions.size()) + ".";
}
TC_LOG_INFO("server.adventurebot.navigation", "LUA_RELOAD guid=%u success=%u root='%s' detail='%s'",
guid, success ? 1u : 0u, _scriptRoot.c_str(), detail.c_str());
return success;
}
bool ClientlessLuaEngine::ExecuteTestScript(Player* player, std::string const& scriptName)
{
if (!player || scriptName.empty() || scriptName.find("..") != std::string::npos || scriptName.find('/') != std::string::npos || scriptName.find('\\') != std::string::npos)
@@ -46,6 +46,7 @@ public:
bool ExecuteAdventureScript(Player* player);
bool ExecuteTestScript(Player* player, std::string const& scriptName);
bool ReloadAdventureScript(Player* player, std::string& detail);
bool LoadAdventureProfile(Player* player);
ClientlessAdventureProfile const* GetAdventureProfile(uint32 characterGuid) const;
@@ -1,5 +1,6 @@
#include "ClientlessPlayerMgr.h"
#include "ClientlessTravelMgr.h"
#include "ClientlessTransportMgr.h"
#include "ClientlessPerceptionMgr.h"
#include "ClientlessActionExecutorMgr.h"
#include "Log.h"
@@ -147,6 +148,7 @@ void ClientlessPlayerMgr::Update(uint32 diff)
// CLIENTLESS_PLAYER_CORE_C8_3_LOCAL_PERCEPTION
sClientlessPerceptionMgr->Update(diff);
// CLIENTLESS_PLAYER_CORE_C8_4_ACTION_EXECUTOR
sClientlessTransportMgr->Update(diff);
sClientlessActionExecutorMgr->Update(diff);
for (auto itr = _records.begin(); itr != _records.end(); )
@@ -157,6 +159,7 @@ void ClientlessPlayerMgr::Update(uint32 diff)
if (state == ClientlessPlayerState::Offline)
{
sClientlessActionExecutorMgr->Clear(characterGuid);
itr = _records.erase(itr);
continue;
}
@@ -0,0 +1,798 @@
#include "ClientlessTransportMgr.h"
#include "Chat.h"
#include "ClientlessPlayerMgr.h"
#include "ClientlessRouteRecorderMgr.h"
#include "ClientlessTravelGraphMgr.h"
#include "ClientlessTravelMgr.h"
#include "DatabaseEnv.h"
#include "GameObject.h"
#include "GameTime.h"
#include "Opcodes.h"
#include "WorldPacket.h"
#include "Log.h"
#include "Map.h"
#include "MotionMaster.h"
#include "ObjectAccessor.h"
#include "ObjectMgr.h"
#include "Player.h"
#include "Transport.h"
#include "TransportMgr.h"
#include "World.h"
#include "WorldSession.h"
#include <algorithm>
#include <cmath>
#include <limits>
#include <sstream>
namespace
{
void PublishPassengerState(Player* player)
{
player->m_movementInfo.guid = player->GetGUID();
player->m_movementInfo.pos.Relocate(player->GetPosition());
player->m_movementInfo.time = GameTime::GetGameTimeMS();
WorldPacket update(SMSG_MOVE_UPDATE);
player->WriteMovementInfo(update);
player->SendMessageToSet(&update, true);
}
char const* PhaseName(ClientlessTransportPhase phase)
{
switch (phase)
{
case ClientlessTransportPhase::Waiting: return "Waiting";
case ClientlessTransportPhase::Boarding: return "Boarding";
case ClientlessTransportPhase::Riding: return "Riding";
case ClientlessTransportPhase::Leaving: return "Leaving";
case ClientlessTransportPhase::Arrived: return "Arrived";
case ClientlessTransportPhase::Failed: return "Failed";
}
return "Unknown";
}
bool Safe(std::string const& s) { return ClientlessRouteRecorderMgr::IsSafeKey(s); }
float Distance(Position const& a, Position const& b) { return a.GetExactDist(&b); }
uint32 Spawn(GameObject const* go) { return go->ToTransport() ? go->GetGUID().GetCounter() : go->GetSpawnId(); }
bool SameService(ClientlessTransportStop const& a, ClientlessTransportStop const& b)
{
return a.Entry == b.Entry && a.Spawn == b.Spawn;
}
Position WorldPoint(GameObject* object, ClientlessTransportPoint const& point)
{
Position result = point.Pos;
if (point.OnTransport)
{
float x, y, z, o;
result.GetPosition(x, y, z, o);
object->ToTransportBase()->CalculatePassengerPosition(x, y, z, &o);
result.Relocate(x, y, z, o);
}
return result;
}
GameObject* Resolve(Player* player, uint32 spawn)
{
if (!player || !player->IsInWorld())
return nullptr;
GameObject* go = player->GetMap()->GetGameObjectBySpawnId(spawn);
return go ? go : player->GetMap()->GetTransport(ObjectGuid(HighGuid::Mo_Transport, spawn));
}
bool LoadNode(std::string const& key, uint32& map, Position& pos)
{
if (!Safe(key))
return false;
QueryResult r = WorldDatabase.PQuery("SELECT w.map,w.position_x,w.position_y,w.position_z,w.orientation "
"FROM vcore_clientless_travel_node n JOIN vcore_clientless_world_point w ON w.point_key=n.world_point_key "
"WHERE n.node_key='%s' AND n.enabled=1 AND w.enabled=1", key.c_str());
if (!r)
return false;
Field* f = r->Fetch();
// vcore_clientless_world_point.map is SMALLINT, not INT. Field access
// must match its width; GetUInt32 returns zero in a debug build.
map = f[0].GetUInt16();
pos.Relocate(f[1].GetFloat(), f[2].GetFloat(), f[3].GetFloat(), f[4].GetFloat());
return true;
}
float PathLength(ClientlessTransportStop const& stop)
{
float sum = 0;
for (std::size_t i = 1; i < stop.Points.size(); ++i)
{
auto world = [&](ClientlessTransportPoint const& p)
{
Position pos = p.Pos;
if (p.OnTransport)
{
float x, y, z, o;
pos.GetPosition(x, y, z, o);
TransportBase::CalculatePassengerPosition(x, y, z, &o,
stop.Dock.GetPositionX(), stop.Dock.GetPositionY(), stop.Dock.GetPositionZ(), stop.Dock.GetOrientation());
pos.Relocate(x, y, z, o);
}
return pos;
};
sum += Distance(world(stop.Points[i - 1]), world(stop.Points[i]));
}
return sum;
}
}
ClientlessTransportMgr* ClientlessTransportMgr::instance()
{
static ClientlessTransportMgr mgr;
return &mgr;
}
bool ClientlessTransportMgr::IsTransportEdge(std::string const& type)
{
return type == "ship" || type == "zeppelin" || type == "tram" || type == "elevator";
}
bool ClientlessTransportMgr::EnsureLoaded()
{
return _loaded ? _available : Reload();
}
bool ClientlessTransportMgr::Reload()
{
_stops.clear();
_loaded = true;
_available = false;
// Keep existing CPC installations usable before the explicit migration.
if (!WorldDatabase.Query("SHOW TABLES LIKE 'vcore_clientless_transport_stop'") ||
!WorldDatabase.Query("SHOW TABLES LIKE 'vcore_clientless_transport_point'") ||
!WorldDatabase.Query("SHOW TABLES LIKE 'vcore_clientless_transport_stand'"))
return false;
_available = true;
QueryResult r = WorldDatabase.Query("SELECT node_key,transport_entry,transport_spawn,map,dock_x,dock_y,dock_z,dock_o,period_ms,stand_name FROM vcore_clientless_transport_stop");
if (r)
do
{
Field* f = r->Fetch();
ClientlessTransportStop stop;
stop.Node = f[0].GetString(); stop.Entry = f[1].GetUInt32(); stop.Spawn = f[2].GetUInt32(); stop.Map = f[3].GetUInt32();
stop.Dock.Relocate(f[4].GetFloat(), f[5].GetFloat(), f[6].GetFloat(), f[7].GetFloat());
stop.Period = f[8].GetUInt32(); stop.Stand = f[9].GetString();
if (Safe(stop.Node) && stop.Entry && stop.Spawn && stop.Period)
_stops.emplace(stop.Node, std::move(stop));
} while (r->NextRow());
r = WorldDatabase.Query("SELECT node_key,point_index,on_transport,x,y,z,o FROM vcore_clientless_transport_point ORDER BY node_key,point_index");
if (r)
do
{
Field* f = r->Fetch();
auto itr = _stops.find(f[0].GetString());
if (itr == _stops.end())
continue;
if (f[1].GetUInt32() != itr->second.Points.size())
{
itr->second.Period = 0;
continue;
}
ClientlessTransportPoint p;
p.OnTransport = f[2].GetBool();
p.Pos.Relocate(f[3].GetFloat(), f[4].GetFloat(), f[5].GetFloat(), f[6].GetFloat());
itr->second.Points.push_back(p);
} while (r->NextRow());
for (auto itr = _stops.begin(); itr != _stops.end(); )
{
auto const& stop = itr->second;
uint32 map = 0; Position node;
bool valid = stop.Period && stop.Points.size() >= 2 && stop.Points.size() <= 512 &&
!stop.Points.front().OnTransport && stop.Points.back().OnTransport &&
LoadNode(stop.Node, map, node) && map == stop.Map && Distance(node, stop.Points.front().Pos) <= 3.0f;
bool boarded = false;
for (auto const& p : stop.Points)
{
if (!Trinity::IsValidMapCoord(p.Pos.GetPositionX(), p.Pos.GetPositionY(), p.Pos.GetPositionZ(), p.Pos.GetOrientation()) ||
(boarded && !p.OnTransport) ||
(p.OnTransport && (std::fabs(p.Pos.GetPositionX()) > 100 ||
std::fabs(p.Pos.GetPositionY()) > 100 || std::fabs(p.Pos.GetPositionZ()) > 100))) valid = false;
boarded |= p.OnTransport;
}
if (!valid || !std::isfinite(PathLength(stop)) || PathLength(stop) > 60.0f)
{
TC_LOG_ERROR("server.adventurebot.navigation", "TRANSPORT_STOP_INVALID node='%s'", stop.Node.c_str());
itr = _stops.erase(itr);
}
else ++itr;
}
TC_LOG_INFO("server.adventurebot.navigation", "TRANSPORT_STOPS_LOADED count=%u", uint32(_stops.size()));
return true;
}
bool ClientlessTransportMgr::HasConnection(std::string const& from, std::string const& to)
{
EnsureLoaded();
auto a = _stops.find(from), b = _stops.find(to);
return a != _stops.end() && b != _stops.end() && SameService(a->second, b->second) &&
(a->second.Map != b->second.Map || Distance(a->second.Dock, b->second.Dock) > 3.0f) &&
Distance(a->second.Points.back().Pos, b->second.Points.back().Pos) <= 3.0f;
}
GameObject* ClientlessTransportMgr::FindObject(Player* player, ClientlessTransportStop const& stop) const
{
if (!player || !player->IsInWorld()) return nullptr;
GameObjectTemplate const* info = sObjectMgr->GetGameObjectTemplate(stop.Entry);
GameObject* object = info && info->type == GAMEOBJECT_TYPE_MO_TRANSPORT
? player->GetMap()->GetTransport(ObjectGuid(HighGuid::Mo_Transport, stop.Spawn))
: player->GetMap()->GetGameObjectBySpawnId(stop.Spawn);
return object && object->GetEntry() == stop.Entry && object->IsInWorld() &&
object->ToTransportBase() && object->isSpawned() && player->IsInPhase(object) ? object : nullptr;
}
bool ClientlessTransportMgr::AtDock(Player* player, GameObject* object, ClientlessTransportStop const& stop, uint32 requiredPause) const
{
TransportDockState state;
return object && player->GetMapId() == stop.Map && object->GetMapId() == stop.Map &&
Distance(*object, stop.Dock) <= 1.0f &&
std::fabs(std::remainder(object->GetOrientation() - stop.Dock.GetOrientation(), float(2 * M_PI))) < 0.10f &&
object->GetTransportDockState(state) && state.Stopped && state.RemainingPause >= requiredPause;
}
bool ClientlessTransportMgr::Sample(Player* player, Recording& recording, bool force)
{
if (!recording.Error.empty())
return false;
auto fail = [&](char const* message) { recording.Error = message; return false; };
if (!player->IsAlive() || player->IsFlying() || player->IsInFlight())
return fail("Record with a living character on foot, not flying or on a taxi.");
GameObject* object = FindObject(player, recording.Stop);
if (!object || player->GetMapId() != recording.Stop.Map || object->GetGUID() != recording.Object ||
!AtDock(player, object, recording.Stop, 0))
return fail("Transport left its recorded dock; cancel and record during a complete stop.");
bool aboard = player->GetTransport() == object->ToTransportBase();
if ((player->GetTransport() && !aboard) || (recording.Attached && !aboard))
return fail("Recorder changed or left the transport. Record land -> deck only.");
ClientlessTransportPoint point;
point.OnTransport = aboard;
point.Pos.Relocate(player->GetPosition());
if (aboard)
{
float x, y, z, o;
point.Pos.GetPosition(x, y, z, o);
object->ToTransportBase()->CalculatePassengerOffset(x, y, z, &o);
if (std::fabs(x) > 100 || std::fabs(y) > 100 || std::fabs(z) > 100)
return fail("Deck point is outside the supported transport bounds.");
point.Pos.Relocate(x, y, z, o);
}
if (!recording.Stop.Points.empty())
{
auto const& previous = recording.Stop.Points.back();
float delta = Distance(WorldPoint(object, previous), WorldPoint(object, point));
if (delta > 8.0f)
return fail("Position jump over 8 yards; do not teleport or fly while recording.");
if (!force && previous.OnTransport == point.OnTransport && delta < 0.75f)
return true;
}
if (recording.Stop.Points.size() >= 512)
return fail("Recording exceeds 512 points.");
recording.Stop.Points.push_back(point);
recording.Attached |= aboard;
if (PathLength(recording.Stop) > 60.0f)
return fail("Dock transition exceeds 60 yards; move the shared node closer to the ramp.");
return true;
}
bool ClientlessTransportMgr::Save(Player* player, std::string const& stand, std::string& error)
{
auto itr = _recordings.find(player->GetGUID().GetCounter());
if (itr == _recordings.end()) { error = "No active recording."; return false; }
Recording& recording = itr->second;
if (!Safe(stand) || !Sample(player, recording, true) || !recording.Attached || recording.Stop.Points.size() < 2 || player->IsFalling())
{
error = recording.Error.empty() ? "Finish aboard the recorded transport and specify a valid stand name." : recording.Error;
return false;
}
auto& stop = recording.Stop;
if (WorldDatabase.PQuery("SELECT 1 FROM vcore_clientless_transport_stop WHERE node_key='%s'", stop.Node.c_str()))
{ error = "This dock has already been saved; explicitly forget it before replacing a recording."; return false; }
uint32 map = 0; Position node;
if (!LoadNode(stop.Node, map, node) || map != stop.Map || Distance(node, stop.Points.front().Pos) > 0.25f)
{ error = "Shared node changed during recording; cancel and start again."; return false; }
auto const& pos = stop.Points.back().Pos;
QueryResult existing = WorldDatabase.PQuery("SELECT x,y,z FROM vcore_clientless_transport_stand WHERE transport_entry=%u AND name='%s'", stop.Entry, stand.c_str());
if (existing)
{
Field* f = existing->Fetch(); Position target;
target.Relocate(f[0].GetFloat(), f[1].GetFloat(), f[2].GetFloat());
if (Distance(pos, target) > 1.5f) { error = "Stand name already exists at another deck point; stand there or use another name."; return false; }
}
WorldDatabaseTransaction trans = WorldDatabase.BeginTransaction();
trans->PAppend("DELETE FROM vcore_clientless_transport_point WHERE node_key='%s'", stop.Node.c_str());
trans->PAppend("INSERT INTO vcore_clientless_transport_stop VALUES ('%s',%u,%u,%u,%.9f,%.9f,%.9f,%.9f,%u,'%s')",
stop.Node.c_str(), stop.Entry, stop.Spawn, stop.Map, stop.Dock.GetPositionX(), stop.Dock.GetPositionY(), stop.Dock.GetPositionZ(), stop.Dock.GetOrientation(), stop.Period, stand.c_str());
uint32 index = 0;
for (auto const& p : stop.Points)
trans->PAppend("INSERT INTO vcore_clientless_transport_point VALUES ('%s',%u,%u,%.9f,%.9f,%.9f,%.9f)",
stop.Node.c_str(), index++, p.OnTransport ? 1u : 0u, p.Pos.GetPositionX(), p.Pos.GetPositionY(), p.Pos.GetPositionZ(), p.Pos.GetOrientation());
trans->PAppend("INSERT IGNORE INTO vcore_clientless_transport_stand VALUES (%u,'%s',%.9f,%.9f,%.9f,%.9f)", stop.Entry, stand.c_str(),
pos.GetPositionX(), pos.GetPositionY(), pos.GetPositionZ(), pos.GetOrientation());
WorldDatabase.DirectCommitTransaction(trans);
std::string nodeKey = stop.Node;
std::size_t pointCount = stop.Points.size();
if (!Reload() || !_stops.count(nodeKey) || _stops.at(nodeKey).Points.size() != pointCount)
{ error = "Recorded stop could not be reloaded; inspect database errors. Unsaved recording retained."; return false; }
_recordings.erase(itr);
return true;
}
bool ClientlessTransportMgr::Start(Player* player, std::string const& from, std::string const& to, std::string& error)
{
if (!player || !player->IsInWorld() || player->GetTransport() || !HasConnection(from, to))
{ error = "Transport stops are not recorded/compatible, or player is already aboard."; return false; }
auto const& source = _stops.at(from);
if (source.Map != player->GetMapId() || Distance(*player, source.Points.front().Pos) > 3.0f)
{ error = "Player has not reached the shared departure node."; return false; }
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;
ride.Status.Phase = ClientlessTransportPhase::Waiting;
ride.Status.Detail = "waiting for recorded transport at departure dock";
sClientlessTravelMgr->CancelTravel(player->GetGUID().GetCounter());
player->StopMoving();
_rides[player->GetGUID().GetCounter()] = std::move(ride);
TC_LOG_INFO("server.adventurebot.navigation", "TRANSPORT_WAIT guid=%u from='%s' to='%s' spawn=%u boardingMs=%u",
player->GetGUID().GetCounter(), from.c_str(), to.c_str(), source.Spawn, _rides.at(player->GetGUID().GetCounter()).RequiredPause);
return true;
}
ClientlessTransportStatus const* ClientlessTransportMgr::GetStatus(uint32 guid) const
{
auto itr = _rides.find(guid);
return itr == _rides.end() ? nullptr : &itr->second.Status;
}
void ClientlessTransportMgr::SetPhase(Player* player, Ride& ride, ClientlessTransportPhase phase, char const* detail)
{
ride.Status = { phase, detail, ride.Abort }; ride.PhaseElapsed = 0; ride.Moving = false;
TC_LOG_INFO("server.adventurebot.navigation", "TRANSPORT_STATE guid=%u phase='%s' from='%s' to='%s' passenger=%u detail='%s'",
player->GetGUID().GetCounter(), PhaseName(phase), ride.From.Node.c_str(), ride.To.Node.c_str(), player->GetTransport() ? 1u : 0u, detail);
}
void ClientlessTransportMgr::Cancel(uint32 guid)
{
auto itr = _rides.find(guid);
if (itr != _rides.end()) itr->second.Abort = true;
}
void ClientlessTransportMgr::UpdateRide(Player* player, Ride& ride, uint32 diff)
{
using Phase = ClientlessTransportPhase;
if (ride.Status.Phase == Phase::Arrived || ride.Status.Phase == Phase::Failed) return;
ride.Elapsed += diff; ride.PhaseElapsed += diff;
if (player->IsBeingTeleported())
{
player->GetSession()->CompleteClientlessTeleport();
return;
}
GameObject* object = FindObject(player, ride.From);
uint32 limit = uint32(std::min<uint64>(1800000, std::max<uint64>(180000, uint64(ride.From.Period) * 3 + 30000)));
auto fail = [&](char const* detail)
{
player->StopMoving();
SetPhase(player, ride, Phase::Failed, detail);
// Never detach an actual passenger in midair or in the sea.
};
if (ride.Elapsed > limit) { fail("transport timeout; any passenger attachment retained"); return; }
if (ride.Status.Phase == Phase::Waiting)
{
if (ride.Abort) { fail("transport cancelled on land"); return; }
if (!AtDock(player, object, ride.From, ride.RequiredPause)) return;
if (Distance(*player, ride.From.Points.front().Pos) > 3.0f)
{ fail("player moved away from departure node while waiting"); return; }
ride.Point = 0;
SetPhase(player, ride, Phase::Boarding, "boarding recorded land-to-deck transition");
}
if (ride.Status.Phase == Phase::Boarding && ride.Abort)
{
player->StopMoving();
if (!player->GetTransport()) { fail("transport cancelled on land"); return; }
SetPhase(player, ride, Phase::Riding, "boarding cancelled; waiting for safe dock");
}
if (ride.Status.Phase == Phase::Riding)
{
if (!object || player->GetTransport() != object->ToTransportBase())
{ fail("transport passenger identity lost"); return; }
ClientlessTransportStop const* arrival = nullptr;
if (AtDock(player, object, ride.To, uint32(PathLength(ride.To) / std::max(1.0f, player->GetSpeed(MOVE_RUN)) * 1000) + uint32(ride.To.Points.size()) * 100 + 1500)) arrival = &ride.To;
else if (ride.Abort && AtDock(player, object, ride.From, ride.RequiredPause)) arrival = &ride.From;
if (!arrival) return;
if (arrival == &ride.From) ride.To = ride.From;
ride.Point = ride.To.Points.size();
SetPhase(player, ride, Phase::Leaving, "leaving via reversed recorded transition");
}
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))
{
if (player->GetTransport())
{
player->StopMoving(); ride.Abort = true;
SetPhase(player, ride, Phase::Riding, "departure during transition; retaining passenger until safe dock");
}
else fail("transport departed during land transition");
return;
}
if (ride.Moving)
{
if (player->IsSplineEnabled())
{
if (ride.PhaseElapsed > ride.PointTimeout) fail("recorded transition spline timeout");
return;
}
auto const& previous = stop.Points[boarding ? ride.Point - 1 : ride.Point];
if (Distance(*player, WorldPoint(object, previous)) > 1.5f)
{ fail("recorded transition stopped before target point"); return; }
if (!previous.OnTransport && player->GetTransport() == object->ToTransportBase())
{
object->ToTransportBase()->RemovePassenger(player);
PublishPassengerState(player);
}
ride.Moving = false;
}
if ((boarding && ride.Point >= stop.Points.size()) || (!boarding && ride.Point == 0))
{
if (boarding)
{
if (player->GetTransport() != object->ToTransportBase()) { fail("boarding did not attach passenger"); return; }
SetPhase(player, ride, Phase::Riding, "aboard; waiting for destination dock");
}
else
{
if (player->GetTransport() || Distance(*player, stop.Points.front().Pos) > 2.0f)
{ fail("shared arrival node not reached on land"); return; }
SetPhase(player, ride, ride.Abort ? Phase::Failed : Phase::Arrived, ride.Abort ? "cancelled at safe dock" : "arrived at shared destination node");
}
return;
}
auto const& point = stop.Points[boarding ? ride.Point : ride.Point - 1];
Position target = WorldPoint(object, point);
if (Distance(*player, target) > 8.0f) { fail("recorded transition has an unsafe gap over 8 yards"); return; }
bool attached = player->GetTransport() == object->ToTransportBase();
if (point.OnTransport && !attached)
{
if (player->GetTransport()) { fail("another passenger owner already attached"); return; }
float x, y, z, o;
player->GetPosition(x, y, z, o);
object->ToTransportBase()->CalculatePassengerOffset(x, y, z, &o);
if (std::fabs(x) > 100 || std::fabs(y) > 100 || std::fabs(z) > 100)
{ fail("passenger attachment outside supported local bounds"); return; }
player->m_movementInfo.transport.Reset();
player->m_movementInfo.transport.pos.Relocate(x, y, z, o);
TransportDockState state; object->GetTransportDockState(state);
player->m_movementInfo.transport.time = state.Progress;
object->ToTransportBase()->AddPassenger(player);
if (player->GetTransport() != object->ToTransportBase())
{ fail("native passenger attachment failed"); return; }
PublishPassengerState(player);
}
// Keep ownership until the first land point is physically reached. If
// departure interrupts disembarkation, the bot remains a real passenger.
if (!point.OnTransport && attached && Distance(*player, target) <= 0.1f)
{
object->ToTransportBase()->RemovePassenger(player);
PublishPassengerState(player);
}
Position path[2]; path[0].Relocate(player->GetPosition()); path[1] = target;
if (Distance(path[0], path[1]) > 0.1f)
player->GetMotionMaster()->MoveSmoothPath(1, path, 2, false);
ride.Moving = Distance(path[0], path[1]) > 0.1f; ride.PhaseElapsed = 0;
ride.PointTimeout = 10000;
if (boarding) ++ride.Point; else --ride.Point;
}
void ClientlessTransportMgr::Update(uint32 diff)
{
for (auto itr = _recordings.begin(); itr != _recordings.end(); )
{
Player* player = ObjectAccessor::FindConnectedPlayer(ObjectGuid(HighGuid::Player, itr->first));
if (!player || !player->IsInWorld()) { itr = _recordings.erase(itr); continue; }
Recording& recording = itr->second;
recording.Timer += diff;
if (recording.Timer >= 100)
{
recording.Timer = 0;
if (recording.Error.empty() && !Sample(player, recording, false) && !recording.Error.empty())
{
TC_LOG_WARN("server.adventurebot.navigation", "TRANSPORT_RECORD_FAILED recorder=%u detail='%s'", itr->first, recording.Error.c_str());
// Keep the buffer and diagnostic; no further automatic samples.
recording.Timer = 0;
}
}
++itr;
}
for (auto itr = _rides.begin(); itr != _rides.end(); )
{
WorldSession* session = sWorld->FindClientlessSession(itr->first);
if (!session || sWorld->GetClientlessPlayerState(itr->first) != ClientlessPlayerState::Online)
{ itr = _rides.erase(itr); continue; }
Player* player = session->GetPlayer();
if (player) UpdateRide(player, itr->second, diff);
++itr;
}
}
bool ClientlessTransportMgr::Command(ChatHandler* handler, char const* args)
{
std::istringstream input(args ? args : "");
std::string command; input >> command;
Player* gm = handler->GetSession() ? handler->GetSession()->GetPlayer() : nullptr;
if (command == "reload") Reload();
if (!EnsureLoaded())
{
handler->SendSysMessage("Transport schema unavailable. Apply sql/custom/world/2026_10_25_00_clientless_transport.sql to the test world DB.");
return true;
}
auto say = [&](std::string const& text) { handler->SendSysMessage(text.c_str()); };
if (command == "services")
{
uint32 requested = 0;
input >> std::ws;
if (input.peek() != std::char_traits<char>::eof())
{
std::string extra;
if (!(input >> requested) || !requested || (input >> extra))
{ say("Usage: .clientless transport services [spawnId]"); return true; }
}
QueryResult rows = requested
? WorldDatabase.PQuery("SELECT guid,entry FROM transports WHERE guid=%u", requested)
: WorldDatabase.Query("SELECT guid,entry FROM transports ORDER BY guid");
if (!rows) { say("No configured native transport service found."); return true; }
do
{
Field* fields = rows->Fetch();
uint32 spawn = fields[0].GetUInt32(), entry = fields[1].GetUInt32();
auto info = sObjectMgr->GetGameObjectTemplate(entry);
auto route = sTransportMgr->GetTransportTemplate(entry);
if (!info || !route) continue;
uint32 stops = 0;
for (auto const& leg : route->PathLegs)
for (auto const& segment : leg.Segments)
if (segment.Delay) ++stops;
handler->PSendSysMessage("Service spawn=%u entry=%u name='%s' periodMs=%u stops=%u legs=%u instanced=%u",
spawn, entry, info->name.c_str(), route->TotalPathTime, stops, uint32(route->PathLegs.size()), route->InInstance ? 1u : 0u);
if (!requested) continue;
uint32 dock = 0;
for (auto const& leg : route->PathLegs)
for (auto const& segment : leg.Segments)
{
if (!segment.Delay) continue;
TransportMovementState state;
size_t index;
auto position = route->ComputePosition(segment.SegmentEndArrivalTimestamp, &state, &index);
if (!position || state != TransportMovementState::WaitingOnPauseWaypoint || index >= route->PathLegs.size())
continue;
handler->PSendSysMessage(" Dock %u: map=%u pose=(%.3f %.3f %.3f %.3f) arrivalMs=%u pauseMs=%u",
++dock, route->PathLegs[index].MapId, position->GetPositionX(), position->GetPositionY(),
position->GetPositionZ(), position->GetOrientation(), segment.SegmentEndArrivalTimestamp, segment.Delay);
}
if (!dock) say(" No scheduled boarding pauses. Do not create a regular passenger edge without a verified stop.");
} while (rows->NextRow());
return true;
}
if (command == "reload")
{
Reload(); sClientlessTravelGraphMgr->Reload();
handler->PSendSysMessage("Transport data reloaded: %u stops. Active rides retain their captured definitions.", uint32(_stops.size()));
return true;
}
if (command == "node")
{
std::string node; input >> node;
uint32 map = 0; Position position;
if (!LoadNode(node, map, position))
say("Transport node lookup failed: specify an existing enabled node.");
else handler->PSendSysMessage("Transport node '%s': map=%u position=(%.3f %.3f %.3f %.3f)",
node.c_str(), map, position.GetPositionX(), position.GetPositionY(), position.GetPositionZ(), position.GetOrientation());
return true;
}
if (command == "status" || command == "cancel" || command == "travel")
{
uint32 guid = 0; input >> guid;
if (!guid) { say("Usage: .clientless transport status|cancel <guid> OR travel <guid> <destinationNode>"); return true; }
if (command == "cancel")
{
auto const* status = GetStatus(guid);
if (!status || status->Phase == ClientlessTransportPhase::Arrived || status->Phase == ClientlessTransportPhase::Failed)
say("No active transport ride to cancel.");
else { Cancel(guid); say("Cancellation requested; a passenger remains attached until a safe recorded stop."); }
return true;
}
if (command == "travel")
{
std::string node; input >> node;
if (!Safe(node)) { say("Specify a valid destination node."); return true; }
ClientlessAction action; action.Name = "travel_to_node";
action.Parameters["node"] = node; action.Parameters["hard_destination"] = "1";
say(sClientlessPlayerMgr->QueueAction(guid, std::move(action)) ? "Travel action queued through the regular graph executor." : "Character is not CPC managed.");
return true;
}
auto itr = _rides.find(guid);
if (itr == _rides.end()) say("No transport ride state for this character.");
else handler->PSendSysMessage("Transport GUID %u: phase=%s from='%s' to='%s' spawn=%u point=%u elapsedMs=%u abort=%u detail='%s'",
guid, PhaseName(itr->second.Status.Phase), itr->second.From.Node.c_str(), itr->second.To.Node.c_str(), itr->second.From.Spawn,
uint32(itr->second.Point), itr->second.Elapsed, itr->second.Abort ? 1u : 0u, itr->second.Status.Detail.c_str());
return true;
}
if (command == "stops")
{
for (auto const& pair : _stops)
{
auto const& s = pair.second;
handler->PSendSysMessage("Stop '%s': entry=%u spawn=%u map=%u points=%u length=%.2f stand='%s' period=%ums dock=(%.3f %.3f %.3f)",
s.Node.c_str(), s.Entry, s.Spawn, s.Map, uint32(s.Points.size()), PathLength(s), s.Stand.c_str(), s.Period,
s.Dock.GetPositionX(), s.Dock.GetPositionY(), s.Dock.GetPositionZ());
}
handler->PSendSysMessage("Recorded transport stops: %u", uint32(_stops.size()));
return true;
}
if (command == "forget")
{
std::string node; input >> node;
if (!Safe(node)) { say("Usage: .clientless transport forget <node> (removes only the recorded dock transition)"); return true; }
WorldDatabaseTransaction trans = WorldDatabase.BeginTransaction();
trans->PAppend("DELETE FROM vcore_clientless_transport_point WHERE node_key='%s'", node.c_str());
trans->PAppend("DELETE FROM vcore_clientless_transport_stop WHERE node_key='%s'", node.c_str());
WorldDatabase.DirectCommitTransaction(trans); Reload(); sClientlessTravelGraphMgr->Reload();
say("Dock recording removed. Shared node, ground routes and deck stands retained; incomplete transport connections are not planned.");
return true;
}
if (command == "link")
{
std::string from, to, type, both; float cost = 0;
input >> from >> to >> type >> cost >> both;
if (!Safe(from) || !Safe(to) || !IsTransportEdge(type) || !std::isfinite(cost) || cost <= 0 || cost > 100000000 ||
(!both.empty() && both != "both") || !HasConnection(from, to))
{ say("Usage: .clientless transport link <fromNode> <toNode> <ship|zeppelin|tram|elevator> <graphCost> [both]. Both stops must use the same spawn and deck stand."); return true; }
auto const& a = _stops.at(from);
GameObjectTemplate const* info = sObjectMgr->GetGameObjectTemplate(a.Entry);
if (!info || ((type == "tram" || type == "elevator") ? info->type != GAMEOBJECT_TYPE_TRANSPORT : info->type != GAMEOBJECT_TYPE_MO_TRANSPORT))
{ say("Edge type does not match the recorded GameObject transport implementation."); return true; }
if (WorldDatabase.PQuery("SELECT id FROM vcore_clientless_travel_edge WHERE from_node_key='%s' AND to_node_key='%s' AND edge_type='%s'", from.c_str(), to.c_str(), type.c_str()))
{ say("This directed transport edge already exists; it was not overwritten."); return true; }
WorldDatabase.DirectPExecute("INSERT INTO vcore_clientless_travel_edge (from_node_key,to_node_key,edge_type,cost,bidirectional,enabled,comment) "
"VALUES ('%s','%s','%s',%.6f,%u,1,'CPC recorded native transport')", from.c_str(), to.c_str(), type.c_str(), cost, both == "both" ? 1u : 0u);
sClientlessTravelGraphMgr->Reload(); say("Transport edge created. The shared stop nodes are used in both directions.");
return true;
}
if (command == "stand")
{
std::string operation, name; input >> operation;
if (operation == "list")
{
uint32 entry = 0; input >> entry;
if (!entry && gm && gm->GetTransport())
if (GameObject* go = gm->GetMap()->GetGameObject(gm->GetTransport()->GetTransportGUID())) entry = go->GetEntry();
if (!entry) { say("Usage: .clientless transport stand list <transportEntry>"); return true; }
QueryResult r = WorldDatabase.PQuery("SELECT name,x,y,z,o FROM vcore_clientless_transport_stand WHERE transport_entry=%u ORDER BY name", entry);
if (r) do { Field* f = r->Fetch(); handler->PSendSysMessage("Stand '%s' entry=%u local=(%.3f %.3f %.3f %.3f)", f[0].GetString().c_str(), entry, f[1].GetFloat(), f[2].GetFloat(), f[3].GetFloat(), f[4].GetFloat()); } while (r->NextRow());
else say("No stands recorded for this entry.");
return true;
}
if (operation == "remove")
{
uint32 entry = 0; input >> entry >> name;
if (!entry || !Safe(name)) { say("Usage: .clientless transport stand remove <entry> <name>"); return true; }
if (WorldDatabase.PQuery("SELECT node_key FROM vcore_clientless_transport_stop WHERE transport_entry=%u AND stand_name='%s' LIMIT 1", entry, name.c_str()))
{ say("Stand is used by a stop. Forget/re-record those transitions before removing it."); return true; }
WorldDatabase.DirectPExecute("DELETE FROM vcore_clientless_transport_stand WHERE transport_entry=%u AND name='%s'", entry, name.c_str());
say("Unused deck stand removed."); return true;
}
input >> name;
if (operation != "add" || !Safe(name) || !gm || !gm->GetTransport() || gm->IsFlying() || gm->IsFalling())
{ say("Stand aboard the intended transport, then use .clientless transport stand add <name>."); return true; }
GameObject* go = gm->GetMap()->GetGameObject(gm->GetTransport()->GetTransportGUID());
if (!go || !go->ToTransportBase()) { say("Transport GameObject not found."); return true; }
float x, y, z, o; gm->GetPosition(x, y, z, o); go->ToTransportBase()->CalculatePassengerOffset(x, y, z, &o);
if (std::fabs(x) > 100 || std::fabs(y) > 100 || std::fabs(z) > 100) { say("Stand outside supported deck bounds."); return true; }
if (WorldDatabase.PQuery("SELECT 1 FROM vcore_clientless_transport_stand WHERE transport_entry=%u AND name='%s'", go->GetEntry(), name.c_str()))
{ say("Stand name already recorded; use a new name to preserve the existing point."); return true; }
WorldDatabase.DirectPExecute("INSERT INTO vcore_clientless_transport_stand VALUES (%u,'%s',%.9f,%.9f,%.9f,%.9f)", go->GetEntry(), name.c_str(), x, y, z, o);
handler->PSendSysMessage("Stand '%s' saved for entry=%u at local (%.3f %.3f %.3f %.3f).", name.c_str(), go->GetEntry(), x, y, z, o);
return true;
}
if (command == "inspect" || command == "near")
{
uint32 spawn = 0, guid = 0;
float radius = 50;
if (command == "inspect") input >> spawn >> guid;
else input >> radius >> guid;
Player* observer = gm;
if (!observer && guid)
if (WorldSession* session = sWorld->FindClientlessSession(guid)) observer = session->GetPlayer();
if (!observer || !observer->IsInWorld()) { say("In console use: transport inspect <spawnId> <botGuid> OR transport near <radius> <botGuid>."); return true; }
auto print = [&](GameObject* go)
{
if (!go || !go->IsTransport() || !go->IsInWorld() || !observer->IsInPhase(go)) return;
TransportDockState state; go->GetTransportDockState(state);
handler->PSendSysMessage("Transport '%s': entry=%u spawn=%u guid='%s' map=%u pos=(%.3f %.3f %.3f %.3f) distance=%.2f stopped=%u remainingPause=%u period=%u progress=%u",
go->GetName().c_str(), go->GetEntry(), Spawn(go), go->GetGUID().ToString().c_str(), go->GetMapId(), go->GetPositionX(), go->GetPositionY(), go->GetPositionZ(), go->GetOrientation(),
observer->GetExactDist(go), state.Stopped ? 1u : 0u, state.RemainingPause, state.Period, state.Progress);
};
if (command == "inspect")
{
GameObject* go = Resolve(observer, spawn);
if (go) print(go); else say("Spawn is not loaded on the observer's current map.");
}
else
{
radius = std::isfinite(radius) ? std::max(1.0f, std::min(200.0f, radius)) : 50;
for (auto const& pair : observer->GetMap()->GetGameObjectBySpawnIdStore())
if (observer->GetExactDist(pair.second) <= radius) print(pair.second);
QueryResult r = WorldDatabase.Query("SELECT guid FROM transports");
if (r) do { GameObject* go = observer->GetMap()->GetTransport(ObjectGuid(HighGuid::Mo_Transport, r->Fetch()[0].GetUInt32())); if (go && observer->GetExactDist(go) <= radius) print(go); } while (r->NextRow());
}
return true;
}
if (command == "record")
{
if (!gm || !gm->IsInWorld()) { say("Dock recording needs an in-game GM player."); return true; }
uint32 guid = gm->GetGUID().GetCounter();
std::string op; input >> op;
if (op == "cancel") { _recordings.erase(guid); say("Your unsaved recording was cancelled."); return true; }
if (op == "info")
{
auto itr = _recordings.find(guid);
if (itr == _recordings.end()) say("No active recording.");
else handler->PSendSysMessage("Recording node='%s' spawn=%u points=%u length=%.2f aboard=%u error='%s'", itr->second.Stop.Node.c_str(), itr->second.Stop.Spawn,
uint32(itr->second.Stop.Points.size()), PathLength(itr->second.Stop), itr->second.Attached ? 1u : 0u, itr->second.Error.c_str());
return true;
}
if (op == "start")
{
std::string node; uint32 spawn = 0; input >> node >> spawn;
if (_recordings.count(guid)) { say("Finish or cancel your existing recording first."); return true; }
Position position; uint32 map = 0;
if (!Safe(node) || !spawn)
{ say("Usage: .clientless transport record start <sharedNode> <spawnId>"); return true; }
if (!LoadNode(node, map, position))
{ handler->PSendSysMessage("Recording not started: node '%s' does not exist or is disabled.", node.c_str()); return true; }
if (map != gm->GetMapId())
{ handler->PSendSysMessage("Recording not started: node map=%u, recorder map=%u.", map, gm->GetMapId()); return true; }
float const nodeDistance = Distance(*gm, position);
if (nodeDistance > 2.0f)
{ handler->PSendSysMessage("Recording not started: distance to node '%s' is %.3f yd (maximum 2.000); node=(%.3f %.3f %.3f), recorder=(%.3f %.3f %.3f).",
node.c_str(), nodeDistance, position.GetPositionX(), position.GetPositionY(), position.GetPositionZ(), gm->GetPositionX(), gm->GetPositionY(), gm->GetPositionZ()); return true; }
if (gm->GetTransport())
{ handler->PSendSysMessage("Recording not started: recorder is already a passenger of '%s'. Start on the platform, before boarding.",
gm->GetTransport()->GetTransportGUID().ToString().c_str()); return true; }
GameObject* go = Resolve(gm, spawn);
if (!go)
{ handler->PSendSysMessage("Recording not started: spawn %u is not loaded on map %u.", spawn, gm->GetMapId()); return true; }
if (!go->ToTransportBase())
{ handler->PSendSysMessage("Recording not started: spawn %u entry %u is not a supported transport.", spawn, go->GetEntry()); return true; }
if (!gm->IsInPhase(go))
{ say("Recording not started: recorder and transport are in different phases."); return true; }
float const transportDistance = gm->GetExactDist(go);
if (transportDistance > 100.0f)
{ handler->PSendSysMessage("Recording not started: spawn %u is %.3f yd away (maximum 100.000); transport=(%.3f %.3f %.3f).",
spawn, transportDistance, go->GetPositionX(), go->GetPositionY(), go->GetPositionZ()); return true; }
TransportDockState state;
if (!go->GetTransportDockState(state))
{ handler->PSendSysMessage("Recording not started: no server dock state available for spawn %u entry %u.", spawn, go->GetEntry()); return true; }
if (!state.Stopped)
{ handler->PSendSysMessage("Recording not started: spawn %u is moving according to its animation; progress=%u/%u ms, remainingPause=%u ms, nodeDistance=%.3f yd, transportDistance=%.3f yd.",
spawn, state.Progress, state.Period, state.RemainingPause, nodeDistance, transportDistance); return true; }
if (WorldDatabase.PQuery("SELECT 1 FROM vcore_clientless_transport_stop WHERE node_key='%s'", node.c_str())) { say("Dock already recorded; use transport forget <node> explicitly before replacing it."); return true; }
Recording recording;
recording.Stop.Node = node; recording.Stop.Entry = go->GetEntry(); recording.Stop.Spawn = Spawn(go);
recording.Stop.Map = map; recording.Stop.Period = state.Period; recording.Stop.Dock.Relocate(go->GetPosition());
recording.Object = go->GetGUID(); recording.NodePosition = position;
recording.Stop.Points.push_back({ false, position });
_recordings[guid] = recording;
say("Recording started. Walk normally onto this transport and to a deck stand during this stop. Then: transport record stop <standName>.");
return true;
}
auto itr = _recordings.find(guid);
if (itr == _recordings.end()) { say("No active recording."); return true; }
if (op == "mark") { bool ok = Sample(gm, itr->second, true); say(ok ? "Transition point marked." : itr->second.Error); return true; }
if (op == "stop")
{
std::string stand, error; input >> stand;
if (Save(gm, stand, error)) { sClientlessTravelGraphMgr->Reload(); say("Dock transition and local deck stand saved. Record the other stop on the same physical transport, ending at the same stand."); }
else say(error);
return true;
}
}
say("Transport tools: services [spawn], node <key>, near [radius], inspect <spawn> [botGuid], stops, stand add|list, record start|mark|info|stop|cancel, link <from> <to> <type> <cost> [both], forget <node>, reload, status|cancel <guid>, travel <guid> <node>.");
return true;
}
@@ -0,0 +1,97 @@
/* Vcore: recorded dock transitions and native clientless transport travel. */
#ifndef VCORE_CLIENTLESS_TRANSPORT_MGR_H
#define VCORE_CLIENTLESS_TRANSPORT_MGR_H
#include "Common.h"
#include "ObjectGuid.h"
#include "Position.h"
#include <string>
#include <unordered_map>
#include <vector>
class Player;
class GameObject;
class ChatHandler;
struct ClientlessTransportPoint
{
bool OnTransport = false;
Position Pos;
};
struct ClientlessTransportStop
{
std::string Node;
uint32 Entry = 0;
uint32 Spawn = 0;
uint32 Map = 0;
uint32 Period = 0;
Position Dock;
std::string Stand;
std::vector<ClientlessTransportPoint> Points;
};
enum class ClientlessTransportPhase : uint8
{
Waiting, Boarding, Riding, Leaving, Arrived, Failed
};
struct ClientlessTransportStatus
{
ClientlessTransportPhase Phase = ClientlessTransportPhase::Failed;
std::string Detail;
bool Cancelled = false;
};
class TC_GAME_API ClientlessTransportMgr
{
public:
static ClientlessTransportMgr* instance();
bool Reload();
bool HasConnection(std::string const& from, std::string const& to);
bool Start(Player* player, std::string const& from, std::string const& to, std::string& error);
ClientlessTransportStatus const* GetStatus(uint32 guid) const;
void Cancel(uint32 guid);
void Update(uint32 diff);
bool Command(ChatHandler* handler, char const* args);
static bool IsTransportEdge(std::string const& type);
private:
struct Recording
{
ClientlessTransportStop Stop;
ObjectGuid Object;
Position NodePosition;
bool Attached = false;
std::string Error;
uint32 Timer = 0;
};
struct Ride
{
ClientlessTransportStatus Status;
ClientlessTransportStop From, To;
std::size_t Point = 0;
uint32 Elapsed = 0;
uint32 PhaseElapsed = 0;
uint32 RequiredPause = 0;
uint32 PointTimeout = 0;
bool Moving = false;
bool Abort = false;
};
bool EnsureLoaded();
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);
void SetPhase(Player* player, Ride& ride, ClientlessTransportPhase phase, char const* detail);
GameObject* FindObject(Player* player, ClientlessTransportStop const& stop) const;
bool AtDock(Player* player, GameObject* object, ClientlessTransportStop const& stop, uint32 requiredPause) const;
bool _loaded = false;
bool _available = false;
std::unordered_map<std::string, ClientlessTransportStop> _stops;
std::unordered_map<uint32, Recording> _recordings;
std::unordered_map<uint32, Ride> _rides;
};
#define sClientlessTransportMgr ClientlessTransportMgr::instance()
#endif
@@ -1,4 +1,5 @@
#include "ClientlessTravelGraphMgr.h"
#include "ClientlessTransportMgr.h"
#include "DatabaseEnv.h"
#include "Log.h"
@@ -100,6 +101,13 @@ bool ClientlessTravelGraphMgr::Reload()
if (edge.Cost <= 0.0f)
edge.Cost = 1.0f;
if (ClientlessTransportMgr::IsTransportEdge(edge.Type) &&
!sClientlessTransportMgr->HasConnection(edge.FromNode, edge.ToNode))
{
TC_LOG_WARN("server.adventurebot.navigation", "TRANSPORT_EDGE_UNAVAILABLE edge=%u from='%s' to='%s'",
edge.DatabaseId, edge.FromNode.c_str(), edge.ToNode.c_str());
continue;
}
_edges[edge.FromNode].push_back(edge);
++_edgeCount;
@@ -428,11 +436,13 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
{
std::string Node;
bool LastWasBridge = false;
// Preserve each validated start entry until same-edge exits are scored.
std::string EntryKey;
};
auto stateKey = [](State const& state)
{
return state.Node + (state.LastWasBridge ? "\x1f" "B" : "\x1f" "R");
return state.Node + (state.LastWasBridge ? "\x1f" "B" : "\x1f" "R") + state.EntryKey;
};
struct QueueItem
@@ -473,6 +483,32 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
};
std::unordered_map<std::string, VirtualEntry> virtualRoots;
// Exact road-section cost from recorded arclength; handles reverse
// travel as well as virtual entry + exit within the SAME edge.
auto roadSectionCost = [&](uint32 edgeId, bool reverse, uint32 begin,
uint32 end, float fullCost) -> float
{
auto itr = _edgePoints.find(edgeId);
if (itr == _edgePoints.end() || end >= itr->second.size() || begin > end)
return std::numeric_limits<float>::infinity();
std::vector<ClientlessTravelEdgePoint> ordered = itr->second;
if (reverse)
std::reverse(ordered.begin(), ordered.end());
float total = 0.0f, section = 0.0f;
for (uint32 i = 1; i < ordered.size(); ++i)
{
float dx = ordered[i].X - ordered[i - 1].X;
float dy = ordered[i].Y - ordered[i - 1].Y;
float dz = ordered[i].Z - ordered[i - 1].Z;
float const segment = std::sqrt(dx * dx + dy * dy + dz * dz);
total += segment;
if (i > begin && i <= end)
section += segment;
}
return total > 0.1f ? fullCost * section / total
: std::numeric_limits<float>::infinity();
};
uint32 startCount = 0;
for (auto const& pair : _nodes)
{
@@ -628,14 +664,17 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
continue;
}
State state{ candidate.Edge.ToNode, false };
State state{ candidate.Edge.ToNode, false,
":entry:" + std::to_string(candidate.Edge.DatabaseId) +
(candidate.Edge.Reverse ? "R:" : "F:") + std::to_string(candidate.Index) };
std::string key = stateKey(state);
if (blockedBridgeKeys && blockedBridgeKeys->count("start:" + candidate.Edge.ToNode))
continue;
float remainingFraction = candidate.Edge.Reverse
? float(candidate.Index) / float(candidate.Count - 1)
: float(candidate.Count - candidate.Index - 1) / float(candidate.Count - 1);
float newCost = (alreadyThere ? 0.0f : nav.PathLength) + candidate.Edge.Cost * remainingFraction;
uint32 const startIndex = candidate.Edge.Reverse
? candidate.Count - 1 - candidate.Index : candidate.Index;
float newCost = (alreadyThere ? 0.0f : nav.PathLength) +
roadSectionCost(candidate.Edge.DatabaseId, candidate.Edge.Reverse,
startIndex, candidate.Count - 1, candidate.Edge.Cost);
auto old = distance.find(key);
if (old != distance.end() && old->second <= newCost)
continue;
@@ -690,10 +729,9 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
player->GetInstanceId(), a, b, NAV_GROUND);
};
// VCORE_C820_VIRTUAL_EXIT_NODES: preflight a bounded, geographical
// shortlist of directed recorded-road exits. Candidate generation does
// not mutate nodes, edges or stored path points. Costs use travelled
// arclength, not a flat per-bridge penalty or waypoint index fraction.
// Select the closest eligible waypoint per directed road geometrically.
// Future destination tiles need not be loaded: local access is estimated
// here and validated by the executor only after reaching the exit.
struct ExitCandidate
{
std::string FromNode;
@@ -705,12 +743,13 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
float DistanceSq = 0.0f;
ClientlessTravelEdgePoint Point;
};
float const locationAccessRadius = locationGoal
? std::min(300.0f, locationGoal->AnchorRadius) : 0.0f;
std::vector<ExitCandidate> rawExitCandidates;
std::unordered_map<std::string, std::vector<ExitCandidate>> exitsByFrom;
uint32 exitTests = 0, exitAccepted = 0;
if (locationGoal)
{
float const radius = std::min(300.0f, locationGoal->AnchorRadius);
float const radius = locationAccessRadius;
std::unordered_set<std::string> scannedDirected;
for (auto const& edgePair : _edges)
{
@@ -740,6 +779,8 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
if (totalLength < 0.1f)
continue;
float travelled = 0.0f;
ExitCandidate nearest;
nearest.DistanceSq = std::numeric_limits<float>::infinity();
for (uint32 i = 1; i + 1 < ordered.size(); ++i)
{
float dx = ordered[i].X - ordered[i - 1].X;
@@ -753,7 +794,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
dy = point.Y - locationGoal->Y;
dz = point.Z - locationGoal->Z;
float const dsq = dx * dx + dy * dy + dz * dz;
if (dsq > radius * radius)
if (dsq > radius * radius || dsq >= nearest.DistanceSq)
continue;
std::string const exitKey = "exit:" + std::to_string(edge.DatabaseId) +
":" + std::to_string(i) + (edge.Reverse ? "R" : "F");
@@ -767,74 +808,30 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
candidate.RoadCost = edge.Cost * (travelled / totalLength);
candidate.DistanceSq = dsq;
candidate.Point = point;
rawExitCandidates.push_back(std::move(candidate));
candidate.LocalCost = std::sqrt(dsq);
nearest = std::move(candidate);
}
if (std::isfinite(nearest.DistanceSq))
rawExitCandidates.push_back(std::move(nearest));
}
}
std::sort(rawExitCandidates.begin(), rawExitCandidates.end(),
[](ExitCandidate const& a, ExitCandidate const& b)
{ return a.DistanceSq < b.DistanceSq; });
// Bound Detour preflight work, not the number of ground edges.
constexpr uint32 MaxExitPreflights = 96;
for (ExitCandidate candidate : rawExitCandidates)
{
if (exitTests >= MaxExitPreflights)
break;
++exitTests;
Position fromPosition, goalPosition;
fromPosition.Relocate(candidate.Point.X, candidate.Point.Y,
candidate.Point.Z, candidate.Point.O);
goalPosition.Relocate(locationGoal->X, locationGoal->Y,
locationGoal->Z, 0.0f);
if (candidate.DistanceSq <= 2.25f)
candidate.LocalCost = 0.0f;
else
{
VcorePositionPathResult const probe = PathGenerator::AnalyzePositions(
locationGoal->MapId, locationGoal->MapId,
player->GetInstanceId(), fromPosition, goalPosition, NAV_GROUND);
if (!probe.IsComplete() || probe.FinalDistance > 1.5f ||
!std::isfinite(probe.PathLength) || probe.PathLength > 500.0f)
continue;
candidate.LocalCost = probe.PathLength;
}
exitsByFrom[candidate.FromNode].push_back(std::move(candidate));
++exitAccepted;
}
for (ExitCandidate const& candidate : rawExitCandidates)
exitsByFrom[candidate.FromNode].push_back(candidate);
TC_LOG_INFO("server.adventurebot.navigation",
"C820_EXIT_PREFLIGHT candidates=%u tested=%u accepted=%u radius=%.1f",
uint32(rawExitCandidates.size()), exitTests, exitAccepted, radius);
"C821_EXIT_CANDIDATES directedEdges=%u radius=%.1f localCost=air-distance validation=on-arrival",
uint32(rawExitCandidates.size()), radius);
}
// Exact road-section cost from recorded arclength; handles reverse
// travel as well as virtual entry + exit within the SAME edge.
auto roadSectionCost = [&](uint32 edgeId, bool reverse, uint32 begin,
uint32 end, float fullCost) -> float
{
auto itr = _edgePoints.find(edgeId);
if (itr == _edgePoints.end() || end >= itr->second.size() || begin > end)
return std::numeric_limits<float>::infinity();
std::vector<ClientlessTravelEdgePoint> ordered = itr->second;
if (reverse)
std::reverse(ordered.begin(), ordered.end());
float total = 0.0f, section = 0.0f;
for (uint32 i = 1; i < ordered.size(); ++i)
{
float dx = ordered[i].X - ordered[i - 1].X;
float dy = ordered[i].Y - ordered[i - 1].Y;
float dz = ordered[i].Z - ordered[i - 1].Z;
float const segment = std::sqrt(dx * dx + dy * dy + dz * dz);
total += segment;
if (i > begin && i <= end)
section += segment;
}
return total > 0.1f ? fullCost * section / total
: std::numeric_limits<float>::infinity();
};
struct ExitSelection
{
bool Active = false;
bool RootPartial = false;
ExitCandidate Candidate;
bool ForwardPartial = false;
uint32 StartIndex = 0;
ClientlessTravelEdgePoint EntryPoint;
} bestExit;
float bestGoalCost = std::numeric_limits<float>::infinity();
std::string bestGoalState;
@@ -915,23 +912,15 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
float dy = candidateNode.Y - locationGoal->Y;
float dz = candidateNode.Z - locationGoal->Z;
float const direct = std::sqrt(dx*dx + dy*dy + dz*dz);
if (direct <= locationGoal->AnchorRadius)
// Use the same local access limit for nodes and road exits.
// Otherwise a distant node can bypass a closer usable exit.
if (direct <= locationAccessRadius)
{
Position a, b;
a.Relocate(candidateNode.X, candidateNode.Y, candidateNode.Z, candidateNode.O);
b.Relocate(locationGoal->X, locationGoal->Y, locationGoal->Z, 0.0f);
float finalCost = std::numeric_limits<float>::infinity();
if (direct <= 1.5f)
finalCost = 0.0f;
else
{
VcorePositionPathResult const probe = PathGenerator::AnalyzePositions(
candidateNode.MapId, candidateNode.MapId,
player->GetInstanceId(), a, b, NAV_GROUND);
if (probe.IsComplete() && probe.FinalDistance <= 1.5f &&
std::isfinite(probe.PathLength) && probe.PathLength <= 500.0f)
finalCost = probe.PathLength;
}
// Node anchors and virtual exits use the same estimate.
// Neither is rejected because a remote tile is unloaded.
std::string const finishKey = "finish:" + candidateNode.Key;
float const finalCost = blockedBridgeKeys && blockedBridgeKeys->count(finishKey)
? std::numeric_limits<float>::infinity() : direct;
if (current.Cost + finalCost < bestGoalCost)
{
bestGoalCost = current.Cost + finalCost;
@@ -1081,13 +1070,34 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
if (entry.Edge->ToNode == from.Key)
continue;
uint32 const entryIndex = entry.Edge->Reverse
? entry.PointCount - 1 - entry.Index : entry.Index;
if (locationGoal)
{
auto exitGroup = exitsByFrom.find(entry.Edge->FromNode);
if (exitGroup != exitsByFrom.end())
for (ExitCandidate const& exit : exitGroup->second)
{
if (exit.EdgeId != entry.Edge->DatabaseId ||
exit.Reverse != entry.Edge->Reverse || exit.EndIndex <= entryIndex)
continue;
float const score = current.Cost + probe.PathLength + exit.LocalCost +
roadSectionCost(exit.EdgeId, exit.Reverse, entryIndex,
exit.EndIndex, entry.Edge->Cost);
if (score >= bestGoalCost)
continue;
bestGoalCost = score;
bestGoalState = currentKey;
bestExit = { true, false, exit, true, entryIndex, *entry.Point };
}
}
State next{ entry.Edge->ToNode, false };
std::string const nextKey = stateKey(next);
float remainingFraction = entry.Edge->Reverse
? float(entry.Index) / float(entry.PointCount - 1)
: float(entry.PointCount - entry.Index - 1) / float(entry.PointCount - 1);
float const cost = current.Cost + 1000.0f + probe.PathLength +
entry.Edge->Cost * remainingFraction;
uint32 const begin = entry.Edge->Reverse
? entry.PointCount - 1 - entry.Index : entry.Index;
float const cost = current.Cost + probe.PathLength +
roadSectionCost(entry.Edge->DatabaseId, entry.Edge->Reverse,
begin, entry.PointCount - 1, entry.Edge->Cost);
auto old = distance.find(nextKey);
if (old != distance.end() && cost >= old->second)
continue;
@@ -1418,7 +1428,22 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
}
else
{
if (bestExit.ForwardPartial)
{
ClientlessTravelRouteStep connector;
connector.NodeKey = "virtual_ground_entry";
connector.NodeType = "road";
connector.EdgeType = "local_bridge";
connector.MapId = bestExit.EntryPoint.MapId;
connector.X = bestExit.EntryPoint.X;
connector.Y = bestExit.EntryPoint.Y;
connector.Z = bestExit.EntryPoint.Z;
connector.O = bestExit.EntryPoint.O;
route.push_back(std::move(connector));
}
ClientlessTravelRouteStep end;
if (bestExit.ForwardPartial)
end.GroundStartIndex = int32(bestExit.StartIndex);
end.NodeKey = "virtual_ground_exit";
end.NodeType = "road";
end.EdgeType = "ground";
+1
View File
@@ -3414,6 +3414,7 @@ void World::UpdateSessions(uint32 diff)
continue;
}
session->CompleteClientlessTeleport();
WorldSessionFilter updater(session);
if (!session->Update(diff, updater))
{
@@ -1,18 +1,3 @@
-- Arthoran completely overrides generic.lua.
log("Arthoran C8.13b bridge test loaded")
--queue_action("approach", {
-- capability = "vendor",
-- max_distance = 40,
-- approach_distance = 2
--})
queue_action("travel_to_location", {
id = "the_gilded_rose"
})
--queue_action("travel_to_node", {
-- node = "lakeshire",
-- bridge_radius = 400,
-- bridge_path_max = 600
--})
-- Native sea-route test: Stormwind -> Ruttheran, no onward action.
log("SHIP_TEST_SW_RUTTHERAN loaded")
queue_action("travel_to_node", {node="ship_ruttheran_br", hard_destination=true})
@@ -12,6 +12,7 @@
#include "ScriptMgr.h"
#include "Chat.h"
#include "ClientlessPlayerMgr.h"
#include "ClientlessTransportMgr.h"
#include "ClientlessTravelMgr.h"
#include "ClientlessPerceptionMgr.h"
#include "ClientlessActionExecutorMgr.h"
@@ -662,6 +663,23 @@ namespace VianoBotCommands
return true;
}
static bool HandleClientlessLuaReload(ChatHandler* handler, char const* args)
{
uint32 guid = 0;
if (!ParseCharacterGuid(handler, args, guid))
{
handler->SendSysMessage("Usage: .clientless luareload <characterGuid>");
return false;
}
WorldSession* session = sWorld->FindClientlessSession(guid);
Player* player = session ? session->GetPlayer() : nullptr;
std::string detail;
bool success = sClientlessLuaEngine->ReloadAdventureScript(player, detail);
handler->PSendSysMessage("Clientless Lua reload GUID %u: %s %s Script root: %s",
guid, success ? "OK." : "FAILED.", detail.c_str(), sClientlessLuaEngine->GetScriptRoot().c_str());
return true;
}
// CLIENTLESS_PLAYER_CORE_C8_1_POINT_NAVIGATION_TEST
static bool HandleClientlessTravelTo(ChatHandler* handler, char const* args)
{
@@ -1141,7 +1159,7 @@ static bool HandleClientlessTaxiImport(ChatHandler* handler, char const* /*args*
WorldDatabase.DirectPExecute(
"UPDATE vcore_clientless_world_point "
"SET category='travel_taxi',map=%u,position_x=%.6f,position_y=%.6f,position_z=%.6f,"
"orientation=0,enabled=1,comment='Auto-imported from TaxiNodes.dbc node %u' "
"orientation=0,comment=IF(enabled=1,'Auto-imported from TaxiNodes.dbc node %u',comment) "
"WHERE point_key='%s'",
uint32(taxiNode->ContinentID),
taxiNode->Pos.X,
@@ -1175,8 +1193,8 @@ static bool HandleClientlessTaxiImport(ChatHandler* handler, char const* /*args*
{
WorldDatabase.DirectPExecute(
"UPDATE vcore_clientless_travel_node "
"SET node_type='taxi',world_point_key='%s',enabled=1,"
"comment='Auto-imported TaxiNodes.dbc node %u' "
"SET node_type='taxi',world_point_key='%s',"
"comment=IF(enabled=1,'Auto-imported TaxiNodes.dbc node %u',comment) "
"WHERE node_key='%s'",
key.c_str(), taxiNodeId, key.c_str());
++updatedNodes;
@@ -1313,8 +1331,20 @@ static bool HandleClientlessTaxiInfo(ChatHandler* handler, char const* args)
return true;
}
static bool HandleClientlessPortalImport(ChatHandler* handler, char const* /*args*/)
static bool HandleClientlessPortalImport(ChatHandler* handler, char const* args)
{
uint32 requested = 0;
std::istringstream input(args ? args : "");
input >> std::ws;
if (input.peek() != std::char_traits<char>::eof())
{
std::string extra;
if (!(input >> requested) || !requested || (input >> extra))
{
handler->SendSysMessage("Usage: .clientless portal import [triggerId]");
return false;
}
}
uint32 imported = 0;
uint32 missingDbc = 0;
uint32 skippedSameMap = 0;
@@ -1340,6 +1370,8 @@ static bool HandleClientlessPortalImport(ChatHandler* handler, char const* /*arg
{
Field* fields = result->Fetch();
uint32 const triggerId = fields[0].GetUInt32();
if (requested && triggerId != requested)
continue;
uint32 const targetMap = fields[2].GetUInt16();
float const targetX = fields[3].GetFloat();
float const targetY = fields[4].GetFloat();
@@ -1353,11 +1385,9 @@ static bool HandleClientlessPortalImport(ChatHandler* handler, char const* /*arg
continue;
}
// C8.13a deliberately imports map transitions only. Same-map AreaTrigger
// teleports require the clientless equivalent of MOVE_TELEPORT_ACK and
// are left for a later extension. The important world/instance/tram
// transitions all use a far map transfer.
if (uint32(source->ContinentID) == targetMap)
// Same-map portals are opt-in by trigger ID; a bulk import retains its
// established scope. Both ACK paths are handled by the executor.
if (!requested && uint32(source->ContinentID) == targetMap)
{
++skippedSameMap;
continue;
@@ -1463,7 +1493,7 @@ static bool HandleClientlessPortalImport(ChatHandler* handler, char const* /*arg
handler->PSendSysMessage(
"Travel graph reloaded: %u node(s), %u directed edge(s)%s. "
"C8.13a executes far-map AreaTrigger portal edges with the normal Trinity handler.",
"AreaTrigger portal edges use the normal Trinity handler and clientless near/far ACK.",
sClientlessTravelGraphMgr->GetNodeCount(),
sClientlessTravelGraphMgr->GetEdgeCount(),
graphLoaded ? "" : " (no usable nodes)");
@@ -1517,7 +1547,7 @@ static bool HandleClientlessPortalInfo(ChatHandler* handler, char const* args)
" target map=%u pos=(%.2f %.2f %.2f o=%.2f) keys=portal_at_%u_source -> portal_at_%u_target%s",
targetMap, fields[2].GetFloat(), fields[3].GetFloat(), fields[4].GetFloat(), fields[5].GetFloat(),
triggerId, triggerId,
uint32(source->ContinentID) == targetMap ? " [same-map: not imported by C8.13a]" : "");
uint32(source->ContinentID) == targetMap ? " [same-map: use targeted import <triggerId>]" : "");
return true;
}
@@ -2104,6 +2134,11 @@ static bool HandleClientlessTravelReload(ChatHandler* handler, char const* /*arg
return true;
}
static bool HandleClientlessTransport(ChatHandler* handler, char const* args)
{
return sClientlessTransportMgr->Command(handler, args);
}
class VianoBotCommandScript : public CommandScript
{
public:
@@ -2129,15 +2164,17 @@ static bool HandleClientlessTravelReload(ChatHandler* handler, char const* /*arg
static std::vector<ChatCommand> clientlessCommands =
{
{ "login", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessLogin, "" },
{ "logout", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessLogout, "" },
{ "info", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessInfo, "" },
{ "luareload", rbac::RBAC_PERM_COMMAND_GM, true, &HandleClientlessLuaReload, "" },
{ "transport", rbac::RBAC_PERM_COMMAND_GM, true, &HandleClientlessTransport, "" },
{ "login", rbac::RBAC_PERM_COMMAND_GM, true, &HandleClientlessLogin, "" },
{ "logout", rbac::RBAC_PERM_COMMAND_GM, true, &HandleClientlessLogout, "" },
{ "info", rbac::RBAC_PERM_COMMAND_GM, true, &HandleClientlessInfo, "" },
{ "travelto", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessTravelTo, "" },
{ "travelinfo", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessTravelInfo, "" },
{ "travelinfo", rbac::RBAC_PERM_COMMAND_GM, true, &HandleClientlessTravelInfo, "" },
{ "navtest", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessNavTest, "" },
{ "navinfo", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessNavInfo, "" },
{ "navinfo", rbac::RBAC_PERM_COMMAND_GM, true, &HandleClientlessNavInfo, "" },
{ "perceive", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessPerceive, "" },
{ "actioninfo", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessActionInfo, "" },
{ "actioninfo", rbac::RBAC_PERM_COMMAND_GM, true, &HandleClientlessActionInfo, "" },
{ "pointreload", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessPointReload, "" },
{ "location", rbac::RBAC_PERM_COMMAND_GM, false, nullptr, "", {
{ "add", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessLocationAdd, "" },
@@ -2169,9 +2206,9 @@ static bool HandleClientlessTravelReload(ChatHandler* handler, char const* /*arg
{ "import", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessTaxiImport, "" },
{ "info", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessTaxiInfo, "" },
} },
{ "portal", rbac::RBAC_PERM_COMMAND_GM, false, nullptr, "", {
{ "import", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessPortalImport, "" },
{ "info", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessPortalInfo, "" },
{ "portal", rbac::RBAC_PERM_COMMAND_GM, true, nullptr, "", {
{ "import", rbac::RBAC_PERM_COMMAND_GM, true, &HandleClientlessPortalImport, "" },
{ "info", rbac::RBAC_PERM_COMMAND_GM, true, &HandleClientlessPortalInfo, "" },
} },
{ "travelreload", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessTravelReload, "" },
};
@@ -2179,7 +2216,7 @@ static bool HandleClientlessTravelReload(ChatHandler* handler, char const* /*arg
static std::vector<ChatCommand> commandTable =
{
{ "viano", rbac::RBAC_PERM_COMMAND_GM, false, nullptr, "", vianoCommands },
{ "clientless", rbac::RBAC_PERM_COMMAND_GM, false, nullptr, "", clientlessCommands },
{ "clientless", rbac::RBAC_PERM_COMMAND_GM, true, nullptr, "", clientlessCommands },
};
return commandTable;
@@ -68,6 +68,15 @@
RealmID = 1
#
# Clientless.ScriptRoot
# Description: Lua root read directly on login and .clientless luareload <GUID>.
# Set an absolute source directory for development without installing scripts.
# VCORE_CLIENTLESS_SCRIPT_DIR overrides this setting if nonempty.
# Restart once after changing the root; Lua edits require no restart.
# Default: "scripts/clientless" (relative to the worldserver working directory)
Clientless.ScriptRoot = "scripts/clientless"
# DataDir
# Description: Data directory setting.
# Important: DataDir needs to be quoted, as the string might contain space characters.
+145
View File
@@ -0,0 +1,145 @@
"""Compile the production reload/bindings with real Lua and deterministic server doubles."""
import pathlib
import subprocess
import tempfile
ROOT = pathlib.Path(__file__).resolve().parents[2]
SOURCE = (ROOT / "src/server/game/World/ClientlessLuaEngine.cpp").read_text()
def function(signature):
start = SOURCE.index(signature)
opening = SOURCE.index("{", start)
depth = 1
end = opening + 1
while depth:
depth += (SOURCE[end] == "{") - (SOURCE[end] == "}")
end += 1
return SOURCE[start:end]
HARNESS = r'''
#include <lua.hpp>
#include <cassert>
#include <cstdint>
#include <string>
#include <vector>
#include <unordered_map>
#include <utility>
using uint32 = uint32_t;
#define TC_LOG_INFO(...) ((void)0)
struct ClientlessIntent {
std::string Name;
std::unordered_map<std::string,std::string> Parameters;
bool Empty() const { return Name.empty(); }
};
using ClientlessAction = ClientlessIntent;
struct ClientlessAdventureProfile { int Revision = 0; };
struct Guid { uint32 GetCounter() const { return 26; } };
struct Player {
bool aboard = false, flying = false, teleport = false;
Guid GetGUID() { return {}; }
bool IsInWorld() { return true; }
bool GetTransport() { return aboard; }
bool IsInFlight() { return flying; }
bool IsBeingTeleported() { return teleport; }
};
enum class ClientlessActionExecutionState { Idle, SelectingTarget, Moving, Completed, Failed };
enum class ClientlessTravelState { Idle, Moving, Arrived };
struct ActionStatus { ClientlessActionExecutionState State = ClientlessActionExecutionState::Idle; };
struct TravelStatus { ClientlessTravelState State = ClientlessTravelState::Idle; };
struct Manager {
bool managed = true;
std::vector<ClientlessAction> actions;
ClientlessIntent intent;
bool IsManaged(uint32) { return managed; }
unsigned GetQueuedActionCount(uint32) { return actions.size(); }
bool QueueAction(uint32, ClientlessAction a) { actions.push_back(std::move(a)); return true; }
bool SetIntent(uint32, ClientlessIntent i) { intent = std::move(i); return true; }
bool ClearIntent(uint32) { intent = {}; return true; }
} manager;
struct Executor { ActionStatus status; ActionStatus* GetStatus(uint32) { return &status; } } executor;
struct Travel { TravelStatus status; TravelStatus* GetStatus(uint32) { return &status; } } travel;
auto* sClientlessPlayerMgr = &manager;
auto* sClientlessActionExecutorMgr = &executor;
auto* sClientlessTravelMgr = &travel;
class ClientlessLuaEngine {
public:
std::unordered_map<uint32,ClientlessAdventureProfile> _profiles;
std::string _scriptRoot = "/test";
std::string script;
bool profileOK = true;
bool LoadAdventureProfile(Player*) {
if (!profileOK) return false;
_profiles[26].Revision++;
return true;
}
bool ExecuteAdventureScript(Player*);
bool ReloadAdventureScript(Player*, std::string&);
};
'''
HARNESS += SOURCE[SOURCE.index("struct ReloadEffects"):SOURCE.index("uint32 BoundGuid")]
for signature in ["uint32 BoundGuid", "std::unordered_map<std::string, std::string> ReadMap",
"int LuaIntend", "int LuaClearIntent", "int LuaQueueAction"]:
HARNESS += function(signature) + "\n"
HARNESS += r'''
bool ClientlessLuaEngine::ExecuteAdventureScript(Player*) {
lua_State* L = luaL_newstate();
luaL_openlibs(L);
for (auto entry : {std::pair{"intend", LuaIntend}, {"clear_intent", LuaClearIntent}, {"queue_action", LuaQueueAction}}) {
lua_pushinteger(L,26); lua_pushcclosure(L,entry.second,1); lua_setglobal(L,entry.first);
}
bool success = luaL_loadstring(L, script.c_str()) == LUA_OK && lua_pcall(L,0,0,0) == LUA_OK;
lua_close(L);
return success;
}
'''
HARNESS += function("bool ClientlessLuaEngine::ReloadAdventureScript")
HARNESS += r'''
int main() {
Player p;
ClientlessLuaEngine engine;
std::string detail;
engine._profiles[26].Revision = 4;
manager.intent.Name = "old";
engine.script = "queue_action('move', {node='tram_if'}); intend('new'); error('failure')";
assert(!engine.ReloadAdventureScript(&p,detail));
assert(manager.actions.empty() && manager.intent.Name == "old");
assert(engine._profiles.at(26).Revision == 4 && !pendingReload);
engine.script = "this is invalid lua";
assert(!engine.ReloadAdventureScript(&p,detail));
assert(engine._profiles.at(26).Revision == 4 && manager.actions.empty());
engine.profileOK = false;
assert(!engine.ReloadAdventureScript(&p,detail));
engine.profileOK = true;
engine.script = "assert(not queue_action('')); queue_action('move', {node='tram_if'}); clear_intent(); intend('new')";
assert(engine.ReloadAdventureScript(&p,detail));
assert(manager.actions.size() == 1 && manager.actions[0].Parameters.at("node") == "tram_if");
assert(manager.intent.Name == "new" && engine._profiles.at(26).Revision == 5);
assert(!engine.ReloadAdventureScript(&p,detail)); // queued actions must not duplicate
manager.actions.clear();
executor.status.State = ClientlessActionExecutionState::Moving;
assert(!engine.ReloadAdventureScript(&p,detail));
executor.status.State = ClientlessActionExecutionState::Completed;
travel.status.State = ClientlessTravelState::Moving;
assert(!engine.ReloadAdventureScript(&p,detail));
travel.status.State = ClientlessTravelState::Arrived;
p.aboard = true; assert(!engine.ReloadAdventureScript(&p,detail)); p.aboard = false;
p.flying = true; assert(!engine.ReloadAdventureScript(&p,detail)); p.flying = false;
p.teleport = true; assert(!engine.ReloadAdventureScript(&p,detail)); p.teleport = false;
assert(!engine.ReloadAdventureScript(nullptr,detail));
manager.managed = false; assert(!engine.ReloadAdventureScript(&p,detail)); manager.managed = true;
engine._profiles.clear(); engine.script = "error('failure')";
assert(!engine.ReloadAdventureScript(&p,detail) && engine._profiles.empty());
engine.script = "clear_intent()";
assert(engine.ReloadAdventureScript(&p,detail) && manager.intent.Empty());
}
'''
with tempfile.TemporaryDirectory(prefix="vcore-lua-reload-") as folder:
source = pathlib.Path(folder) / "test.cpp"
binary = pathlib.Path(folder) / "test"
source.write_text(HARNESS)
flags = subprocess.check_output(["pkg-config", "--cflags", "--libs", "lua5.4"], text=True).split()
subprocess.run(["c++", "-std=c++17", str(source), "-o", str(binary), *flags], check=True)
subprocess.run([str(binary)], check=True)
print("Lua reload regression tests passed (production reload/bindings, real Lua 5.4).")
+83
View File
@@ -0,0 +1,83 @@
"""Exercise the actual portal handler/ACK/destination checks with Core doubles."""
from pathlib import Path
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[2]
source = (ROOT / "src/server/game/World/ClientlessActionExecutorMgr.cpp").read_text()
start = source.index(" WorldPacket areaTriggerPacket(CMSG_AREATRIGGER, 4);")
end = source.index(" record.Status.State = ClientlessActionExecutionState::Moving;", start)
fragment = source[start:end]
assert "same-map portal transition is not supported" not in source
harness = r'''
#include <cassert>
#include <cmath>
#include <string>
#define TC_LOG_WARN(...) ((void)0)
enum class ClientlessActionExecutionState { Idle, Failed, Moving };
constexpr int CMSG_AREATRIGGER=1;
struct WorldPacket { WorldPacket(int,int){} void operator<<(int){} };
struct Player;
struct Session {
Player* p;
int accepted=0,ackCount=0;
bool clientless=true,ackWorks=true;
void HandleAreaTriggerOpcode(WorldPacket&);
void CompleteClientlessTeleport();
bool IsClientless() { return clientless; }
};
struct Player {
int map=1; float x=8393,y=1003,z=30;
bool pending=false,world=true;
Session session{this};
bool IsBeingTeleported() { return pending; }
bool IsBeingTeleportedFar() { return pending&&map==0; }
bool IsInWorld() { return world; }
int GetMapId() { return map; }
float GetPositionX() { return x; } float GetPositionY() { return y; } float GetPositionZ() { return z; }
Session* GetSession() { return &session; }
};
struct Teleport { int target_mapId=1; float target_X=9946,target_Y=2612,target_Z=1316; } destination;
void Session::HandleAreaTriggerOpcode(WorldPacket&) { if (accepted) p->pending=true; }
void Session::CompleteClientlessTeleport() {
++ackCount;
if (!ackWorks) return;
p->pending=false;p->map=destination.target_mapId;
p->x=destination.target_X;p->y=destination.target_Y;p->z=destination.target_Z;
}
struct Record {
struct {ClientlessActionExecutionState State=ClientlessActionExecutionState::Idle; std::string Detail,TargetName;} Status;
bool Active=true;
} record;
struct {int EdgeDatabaseId=1;std::string NodeKey="portal_at_542_target";} step;
bool transfer(Player* player) {
int triggerId=542,characterGuid=26;
auto teleport=&destination;
'''
harness += fragment
harness += r'''
return true;
}
int main() {
Player denied;
assert(!transfer(&denied)&&!record.Active&&record.Status.State==ClientlessActionExecutionState::Failed);
Player near;near.session.accepted=1;record={};
assert(transfer(&near)&&near.session.ackCount==1&&near.map==1&&near.x==9946&&!near.pending);
Player far;far.map=0;far.session.accepted=1;record={};
assert(transfer(&far)&&far.session.ackCount==1&&far.map==1&&!far.pending);
Player pending;pending.session.accepted=1;pending.session.ackWorks=false;record={};
assert(!transfer(&pending)&&pending.pending&&!record.Active);
Player network;network.session.accepted=1;network.session.clientless=false;record={};
assert(!transfer(&network)&&!record.Active);
Player wrongMap;wrongMap.map=0;record={};assert(!transfer(&wrongMap));
Player alreadyAtTarget;alreadyAtTarget.x=destination.target_X;alreadyAtTarget.y=destination.target_Y;alreadyAtTarget.z=destination.target_Z;
record={};assert(transfer(&alreadyAtTarget)); // destination reached independently is valid
}
'''
with tempfile.TemporaryDirectory(prefix="vcore-portal-test-") as folder:
cpp, binary = Path(folder) / "test.cpp", Path(folder) / "test"
cpp.write_text(harness)
subprocess.run(["c++", "-std=c++17", str(cpp), "-o", str(binary)], check=True)
subprocess.run([str(binary)], check=True)
print("Portal near/far ACK and denied same-map transfer regressions passed.")
@@ -0,0 +1,23 @@
"""Execute production TaxiImport UPDATE statements against enabled/disabled rows."""
from pathlib import Path
import ast,re,sqlite3
source=(Path(__file__).resolve().parents[2]/'src/server/scripts/Custom/viano_bot_commands.cpp').read_text()
source=source[source.index('static bool HandleClientlessTaxiImport'):source.index('static bool HandleClientlessPortalImport')]
statements=[]
for group in re.findall(r'WorldDatabase\.DirectPExecute\(\s*((?:"[^"\n]*"\s*)+),',source):
query=''.join(ast.literal_eval(s) for s in re.findall(r'"[^"\n]*"',group))
if query.startswith('UPDATE '): statements.append(query)
assert len(statements)==2
for enabled in (0,1):
db=sqlite3.connect(':memory:')
db.create_function('IF',3,lambda condition,yes,no:yes if condition else no)
db.executescript('CREATE TABLE vcore_clientless_world_point (point_key TEXT,category TEXT,map INT,position_x REAL,position_y REAL,position_z REAL,orientation REAL,enabled INT,comment TEXT); CREATE TABLE vcore_clientless_travel_node (node_key TEXT,node_type TEXT,world_point_key TEXT,enabled INT,comment TEXT);')
db.execute('INSERT INTO vcore_clientless_world_point VALUES (?,?,?,?,?,?,?,?,?)',('taxi_585','travel_taxi',1,0,0,0,0,enabled,'manual state'))
db.execute('INSERT INTO vcore_clientless_travel_node VALUES (?,?,?,?,?)',('taxi_585','taxi','taxi_585',enabled,'manual state'))
db.execute(statements[0]%(1,8334.76,1029.83,22.7917,585,'taxi_585'))
db.execute(statements[1]%('taxi_585',585,'taxi_585'))
for table in ('vcore_clientless_world_point','vcore_clientless_travel_node'):
flag,comment=db.execute('SELECT enabled,comment FROM '+table).fetchone()
assert flag==enabled
assert comment.startswith('Auto-imported') if enabled else comment=='manual state'
print('Taxi importer preserves disabled rows and their reasons.')
+214
View File
@@ -0,0 +1,214 @@
#!/usr/bin/env python3
"""Exercise production dock-state/recorder/executor methods with deterministic world doubles.
Run: python3 tests/clientless/test_transport.py
Requires g++ and Python; does not connect to a server or database. Real collision,
network visibility and map-transfer integration still require server tests.
"""
from pathlib import Path
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[2]
def function(text, marker):
start = text.index(marker)
opening = text.index('{', start)
depth = 1
end = opening + 1
while depth:
depth += (text[end] == '{') - (text[end] == '}')
end += 1
return text[start:end]
source = (ROOT / 'src/server/game/World/ClientlessTransportMgr.cpp').read_text()
header = (ROOT / 'src/server/game/World/ClientlessTransportMgr.h').read_text()
header = '\n'.join(line for line in header.splitlines() if not line.startswith('#'))
header = header.replace('private:', 'public:').replace('TC_GAME_API ', '')
core = (ROOT / 'src/server/game/Entities/GameObject/GameObject.cpp').read_text()
base = (ROOT / 'src/server/game/Entities/Vehicle/VehicleDefines.h').read_text()
position = function(base, ' static void CalculatePassengerPosition(')
offset = function(base, ' static void CalculatePassengerOffset(')
preamble = r'''
#include <algorithm>
#include <cassert>
#include <cmath>
#include <cstdint>
#include <iostream>
#include <limits>
#include <map>
#include <string>
#include <unordered_map>
#include <vector>
using uint8=uint8_t; using uint32=uint32_t; using uint64=uint64_t; using int32=int32_t;
#define TC_LOG_INFO(...) do {} while(0)
#define TC_LOG_WARN(...) do {} while(0)
#define TC_LOG_ERROR(...) do {} while(0)
struct ObjectGuid {uint32 id=0;uint32 GetCounter() const{return id;}bool operator==(ObjectGuid b)const{return id==b.id;}bool operator!=(ObjectGuid b)const{return id!=b.id;}};
struct Position {
float x=0,y=0,z=0,o=0;
static float NormalizeOrientation(float a){a=std::fmod(a,float(2*M_PI));return a<0?a+2*M_PI:a;}
void Relocate(float a,float b,float c,float d=0){x=a;y=b;z=c;o=d;}
void Relocate(Position const& p){*this=p;}
Position const& GetPosition()const{return *this;}
void GetPosition(float&a,float&b,float&c,float&d)const{a=x;b=y;c=z;d=o;}
float GetPositionX()const{return x;}float GetPositionY()const{return y;}float GetPositionZ()const{return z;}float GetOrientation()const{return o;}
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;
struct TransportDockState {uint32 Progress=0,Period=0,RemainingPause=0;bool Stopped=false;};
class Player; class GameObject;
class TransportBase {
public:
GameObject* go=nullptr;
void AddPassenger(Player*);void RemovePassenger(Player*);
void CalculatePassengerPosition(float&,float&,float&,float*)const;
void CalculatePassengerOffset(float&,float&,float&,float*)const;
'''
world = r'''
};
class GameObject:public Position {
public:
TransportBase base;uint32 map=0,entry=1,spawn=1;TransportDockState state{0,30000,10000,true};
GameObject(){base.go=this;}
bool IsInWorld()const{return true;}uint32 GetMapId()const{return map;}
ObjectGuid GetGUID()const{return {spawn};}
TransportBase* ToTransportBase(){return &base;}
bool GetTransportDockState(TransportDockState&s)const{s=state;return true;}
uint32 deadline=0; uint32 GetUInt32Value(int)const{return deadline;}
};
struct Session {int completed=0;void CompleteClientlessTeleport() {++completed;}} session;
struct Motion {Player*p; void MoveSmoothPath(int,Position* path,int,bool);};
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;
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;}
ObjectGuid GetGUID()const{return {26};}uint32 GetMapId()const{return map;}
float GetSpeed(int)const{return 7;}void StopMoving(){}bool IsSplineEnabled()const{return false;}
Motion* GetMotionMaster(){return &motion;}Session* GetSession(){return &session;}
};
void TransportBase::CalculatePassengerPosition(float&x,float&y,float&z,float*o)const{CalculatePassengerPosition(x,y,z,o,go->x,go->y,go->z,go->o);}
void TransportBase::CalculatePassengerOffset(float&x,float&y,float&z,float*o)const{CalculatePassengerOffset(x,y,z,o,go->x,go->y,go->z,go->o);}
void TransportBase::AddPassenger(Player*p){p->transport=this;p->m_movementInfo.transport.guid=go->GetGUID();}
void TransportBase::RemovePassenger(Player*p){p->transport=nullptr;p->m_movementInfo.transport.Reset();}
void Motion::MoveSmoothPath(int,Position* path,int,bool){p->Relocate(path[1]);if(p->transport){Position pos=*p;p->transport->CalculatePassengerOffset(pos.x,pos.y,pos.z,&pos.o);p->m_movementInfo.transport.pos=pos;}}
struct Travel {void CancelTravel(uint32){}}travel;
auto sClientlessTravelMgr=&travel;
// Model the real Field API's strict debug type checks for SMALLINT versus INT.
struct Field {
bool small=false;uint32 number=0;float real=0;
uint32 GetUInt32()const{return small?0:number;}
uint16_t GetUInt16()const{return small?uint16_t(number):0;}
float GetFloat()const{return real;}
};
struct Result {Field fields[5]={{true,369,0},{false,0,1},{false,0,2},{false,0,3},{false,0,0}};Field* Fetch(){return fields;}};
using QueryResult=Result*;
struct Database {Result result;template<class...Args>QueryResult PQuery(char const*,Args...){return &result;}} WorldDatabase;
bool Safe(std::string const& key){return !key.empty();}
namespace Trinity {bool IsValidMapCoord(float,float,float,float=0){return true;}}
'''
helpers = '\n'.join(function(source, marker) for marker in [
' float Distance(', ' bool SameService(', ' bool LoadNode(', ' Position WorldPoint(', ' float PathLength('
])
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::UpdateRide('
])
stubs = r'''
void PublishPassengerState(Player*){}
bool ClientlessTransportMgr::EnsureLoaded(){return true;}
GameObject* ClientlessTransportMgr::FindObject(Player*p,ClientlessTransportStop const&s)const{return p->object && p->object->entry==s.Entry && p->object->spawn==s.Spawn ? p->object : nullptr;}
'''
animated = r'''
namespace GameTime {uint32 now=0;uint32 GetGameTimeMS(){return now;}}
constexpr int GAMEOBJECT_LEVEL=0;
struct TransportAnimationEntry {uint32 TimeIndex=0;struct{float X=0,Y=0,Z=0;}Pos;};
struct TransportRotationEntry {float X=0,Y=0,Z=0,W=1;};
struct TransportAnimation {uint32 TotalTime=30033;std::map<uint32,TransportAnimationEntry const*>Path;std::map<uint32,TransportRotationEntry const*>Rotations;};
struct Animated {
GameObject _owner;TransportAnimation* _animationInfo=nullptr;uint32 _pathProgress=0;std::vector<uint32>_stopFrames;
'''+function(core, ' bool GetTransportDockState(TransportDockState& state) const override').replace(' override','')+'\n};\n'
tests = r'''
ClientlessTransportPoint point(bool on,float x){ClientlessTransportPoint p;p.OnTransport=on;p.Pos.x=x;return p;}
ClientlessTransportStop stop(std::string node,float dock,float land){ClientlessTransportStop s;s.Node=node;s.Entry=1;s.Spawn=1;s.Map=0;s.Period=30000;s.Dock.x=dock;s.Points={point(false,land),point(false,land>dock?land-1:land+1),point(true,1)};return s;}
using Phase=ClientlessTransportPhase;
void run(ClientlessTransportMgr&m,Player&p,ClientlessTransportMgr::Ride&r,int n=20){for(int i=0;i<n;i++)m.UpdateRide(&p,r,100);}
int main(){
// Regression: a real-world-point SMALLINT map must survive loading as 369.
uint32 loadedMap=0;Position loadedPosition;assert(LoadNode("tram_sw",loadedMap,loadedPosition));assert(loadedMap==369);assert(loadedPosition.x==1&&loadedPosition.y==2&&loadedPosition.z==3);
// Actual animation-state method: no state change is required for an animation pause.
TransportAnimationEntry a{0,{0,0,0}},b{5000,{0,0,0}},c{15000,{0,0,-60}},d{20000,{0,0,-60}},e{30033,{0,0,0}};
TransportAnimation anim;anim.Path={{0,&a},{5000,&b},{15000,&c},{20000,&d},{30033,&e}};
Animated animated;animated._animationInfo=&anim;TransportDockState snapshot;
animated._pathProgress=1000;assert(animated.GetTransportDockState(snapshot));assert(snapshot.Stopped&&snapshot.RemainingPause==4000);
animated._pathProgress=5000;animated.GetTransportDockState(snapshot);assert(!snapshot.Stopped);
animated._pathProgress=17000;animated.GetTransportDockState(snapshot);assert(snapshot.Stopped&&snapshot.RemainingPause==3000);
TransportRotationEntry r0,r1;r1.Z=1;anim.Rotations={{0,&r0},{30033,&r1}};animated.GetTransportDockState(snapshot);assert(!snapshot.Stopped);
anim.Rotations.clear();animated._stopFrames={1000};animated._owner.deadline=2000;GameTime::now=1000;animated.GetTransportDockState(snapshot);assert(!snapshot.Stopped);
GameTime::now=2000;animated.GetTransportDockState(snapshot);assert(snapshot.Stopped&&snapshot.RemainingPause==std::numeric_limits<uint32>::max());
// Round-trip the actual core transform for a rotated transport.
float x=3,y=4,z=5,o=0.7f;TransportBase::CalculatePassengerPosition(x,y,z,&o,100,200,300,1.5708f);TransportBase::CalculatePassengerOffset(x,y,z,&o,100,200,300,1.5708f);
assert(std::fabs(x-3)<0.01f&&std::fabs(y-4)<0.01f&&std::fabs(z-5)<0.01f&&std::fabs(o-0.7f)<0.01f);
ClientlessTransportMgr mgr;mgr._stops["A"]=stop("A",5,0);mgr._stops["B"]=stop("B",100,104);
assert(mgr.HasConnection("A","B"));mgr._stops["B"].Spawn=2;assert(!mgr.HasConnection("A","B"));mgr._stops["B"].Spawn=1;
Player p;GameObject go;go.x=5;p.object=&go;std::string error;
assert(mgr.Start(&p,"A","B",error));auto&r=mgr._rides.at(26);
go.state.RemainingPause=100;run(mgr,p,r);assert(r.Status.Phase==Phase::Waiting&&!p.transport);
go.state.RemainingPause=10000;run(mgr,p,r);assert(r.Status.Phase==Phase::Riding&&p.transport==&go.base);assert(std::fabs(p.m_movementInfo.transport.pos.x-1)<0.01);
// Wrong location despite a stopped object must not allow disembarkation.
go.x=50;p.x=51;run(mgr,p,r);assert(r.Status.Phase==Phase::Riding&&p.transport);
go.x=100;p.x=101;run(mgr,p,r);assert(r.Status.Phase==Phase::Arrived&&!p.transport&&p.x==104);
// Cancellation on land does not attach a passenger.
p.x=0;go.x=5;assert(mgr.Start(&p,"A","B",error));mgr.Cancel(26);auto&cancel=mgr._rides.at(26);run(mgr,p,cancel);assert(cancel.Status.Phase==Phase::Failed&&cancel.Status.Cancelled&&!p.transport);
// Midride cancellation retains the Passenger until a recorded safe dock.
p.x=0;assert(mgr.Start(&p,"A","B",error));auto&abort=mgr._rides.at(26);run(mgr,p,abort);assert(p.transport);
go.x=50;p.x=51;mgr.Cancel(26);run(mgr,p,abort);assert(abort.Status.Phase==Phase::Riding&&p.transport);
go.x=5;p.x=6;run(mgr,p,abort);assert(abort.Status.Phase==Phase::Failed&&abort.Status.Cancelled&&!p.transport&&p.x==0);
// A timeout aboard never removes the passenger over sea/in midair.
assert(mgr.Start(&p,"A","B",error));auto&timeout=mgr._rides.at(26);run(mgr,p,timeout);assert(p.transport);timeout.Elapsed=1800001;run(mgr,p,timeout,1);assert(timeout.Status.Phase==Phase::Failed&&p.transport);go.base.RemovePassenger(&p);
// Departure during a boarding transition keeps the Passenger until safe recovery.
p.x=0;go.x=5;go.state.Stopped=true;assert(mgr.Start(&p,"A","B",error));auto&late=mgr._rides.at(26);
for(int i=0;i<10&&!p.transport;i++)mgr.UpdateRide(&p,late,100);
assert(p.transport);go.state.Stopped=false;mgr.UpdateRide(&p,late,100);assert(late.Status.Phase==Phase::Riding&&p.transport);
go.state.Stopped=true;run(mgr,p,late);assert(late.Status.Phase==Phase::Failed&&!p.transport);
// Recorder captures the coordinate-frame switch and rejects leaving the vessel.
p.x=0;ClientlessTransportMgr::Recording rec;rec.Stop=mgr._stops.at("A");rec.Stop.Points={point(false,0)};rec.Object=go.GetGUID();
p.x=1;assert(mgr.Sample(&p,rec,true));p.x=6;go.base.AddPassenger(&p);assert(mgr.Sample(&p,rec,true));assert(rec.Stop.Points.back().OnTransport&&std::fabs(rec.Stop.Points.back().Pos.x-1)<0.01);
go.base.RemovePassenger(&p);assert(!mgr.Sample(&p,rec,true));assert(!rec.Error.empty());
// Boarding onto the wrong vehicle and position jumps do not produce recordings.
ClientlessTransportMgr::Recording jump;jump.Stop=mgr._stops.at("A");jump.Stop.Points={point(false,0)};jump.Object=go.GetGUID();p.x=20;assert(!mgr.Sample(&p,jump,true));
// Native ships switch between per-map copies of the same spawn. The executor
// must resolve the new copy, preserve the ride and leave only at the target.
ClientlessTransportMgr ships;
ships._stops["harbor_a"]=stop("harbor_a",5,0);ships._stops["harbor_a"].Map=1;
ships._stops["harbor_b"]=stop("harbor_b",100,104);ships._stops["harbor_b"].Map=0;
Player sailor;GameObject departure;departure.x=5;departure.map=1;sailor.map=1;sailor.object=&departure;
assert(ships.Start(&sailor,"harbor_a","harbor_b",error));auto& crossing=ships._rides.at(26);
run(ships,sailor,crossing);assert(crossing.Status.Phase==Phase::Riding&&sailor.transport==&departure.base);
sailor.pending=true;sailor.object=nullptr;
ships.UpdateRide(&sailor,crossing,100);
assert(session.completed==1&&crossing.Status.Phase==Phase::Riding); // do not fail during ACK
// Model the native worldport ACK's transfer to the destination-map copy.
GameObject destination;destination.x=50;destination.map=0;
sailor.pending=false;departure.base.RemovePassenger(&sailor);sailor.map=0;sailor.object=&destination;
destination.base.AddPassenger(&sailor);sailor.x=51;
run(ships,sailor,crossing);assert(crossing.Status.Phase==Phase::Riding&&sailor.transport==&destination.base);
destination.x=100;sailor.x=101;
run(ships,sailor,crossing);assert(crossing.Status.Phase==Phase::Arrived&&!sailor.transport&&sailor.x==104&&sailor.map==0);
std::cout<<"Transport dock, frame, recorder, cross-map copy and ride regressions passed\n";
}
'''
with tempfile.TemporaryDirectory(prefix='vcore-transport-test-') as directory:
path = Path(directory)
cpp = path / 'transport.cpp'
cpp.write_text(preamble + position + '\n' + offset + world + header + helpers + stubs + methods + animated + tests)
subprocess.run(['g++', '-std=c++17', '-O0', '-g', '-Wall', '-Wextra', '-Wno-unused-parameter', str(cpp), '-o', str(path / 'test')], check=True)
subprocess.run([str(path / 'test')], check=True)
@@ -0,0 +1,110 @@
"""Exercise actual core grid initialization without bots or a world database."""
from pathlib import Path
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[2]
def block(source, signature):
start = source.index(signature)
end = source.index("{", start) + 1
depth = 1
while depth:
depth += (source[end] == "{") - (source[end] == "}")
end += 1
return source[start:end]
maps = (ROOT / "src/server/game/Maps/Map.cpp").read_text()
objects = (ROOT / "src/server/game/Globals/ObjectMgr.cpp").read_text()
manager = (ROOT / "src/server/game/Maps/MapManager.cpp").read_text()
registration = block(block(objects, "void ObjectMgr::LoadGameObjects()"), "if (gameEvent == 0)")
for function in ("Map* MapManager::CreateWorldMap", "InstanceMap* MapManager::CreateInstance",
"BattlegroundMap* MapManager::CreateBattleground"):
assert "map->LoadTransportGameObjectGrids();" in block(manager, function)
assert manager.index("map->LoadRespawnTimes();") < manager.index("map->LoadTransportGameObjectGrids();")
harness = r'''
#include <cassert>
#include <cstdint>
#include <unordered_map>
#include <vector>
#include <set>
#include <utility>
using uint32 = uint32_t;
struct ObjectGuid { using LowType = uint32; };
#define TC_LOG_INFO(...) ((void)0)
constexpr int GAMEOBJECT_TYPE_TRANSPORT = 11;
struct Position {
float x,y;
float GetPositionX() const { return x; }
float GetPositionY() const { return y; }
};
struct GameObjectData { uint32 mapId,spawnMask; Position spawnPoint; };
struct Template { int type; };
struct ObjectManager {
std::unordered_map<uint32,std::vector<uint32>> _transportGameObjectSpawns;
std::unordered_map<uint32,GameObjectData> data;
unsigned ordinaryGridRegistrations = 0;
void AddGameobjectToGrid(uint32,GameObjectData*) { ++ordinaryGridRegistrations; }
void Register(int gameEvent, uint32 guid, GameObjectData data, Template const* gInfo) {
'''
harness += registration
harness += r'''
}
auto GetTransportGameObjectSpawns(uint32 id) const -> std::vector<uint32> const* {
auto itr = _transportGameObjectSpawns.find(id);
return itr==_transportGameObjectSpawns.end() ? nullptr : &itr->second;
}
auto GetGameObjectData(uint32 id) const -> GameObjectData const* {
auto itr=data.find(id); return itr==data.end() ? nullptr : &itr->second;
}
} objects;
auto* sObjectMgr = &objects;
struct Cell { float x,y; Cell(float a,float b):x(a),y(b){} };
class Map {
public:
uint32 id=369, mode=0;
std::set<std::pair<int,int>> loaded;
uint32 GetId() { return id; }
uint32 GetInstanceId() { return 0; }
uint32 GetSpawnMode() { return mode; }
bool EnsureGridLoaded(Cell c) { return loaded.emplace(int(c.x/533),int(c.y/533)).second; }
void LoadTransportGameObjectGrids();
};
'''
harness += block(maps, "void Map::LoadTransportGameObjectGrids()")
harness += r'''
int main() {
Template tram{11}, ship{15}, chest{3};
GameObjectData sw{369,1,{4,2490}}, ironforge{369,1,{4,8}};
objects.Register(0,18803,ironforge,&tram);
objects.Register(0,18804,sw,&tram);
objects.Register(7,1,sw,&tram); // inactive/active events retain their own lifecycle
objects.Register(-7,2,sw,&tram);
objects.Register(0,3,sw,&ship); // separate native moving-transport loader
objects.Register(0,4,sw,&chest);
assert(objects.ordinaryGridRegistrations==4);
assert(objects.GetTransportGameObjectSpawns(369)->size()==2);
objects.data.emplace(18803,ironforge); objects.data.emplace(18804,sw);
Map map;
// Both station grids must load without any player, bot or transport node.
map.LoadTransportGameObjectGrids();
assert(map.loaded.size()==2);
map.LoadTransportGameObjectGrids(); assert(map.loaded.size()==2);
Map unrelated; unrelated.id=0; unrelated.LoadTransportGameObjectGrids();
assert(unrelated.loaded.empty());
Map differentDifficulty; differentDifficulty.mode=1;
differentDifficulty.LoadTransportGameObjectGrids(); assert(differentDifficulty.loaded.empty());
objects.data.erase(18803); // removed spawn indexed earlier must be skipped
objects.data[18804].mapId=0; // likewise a moved spawn
Map removed; removed.LoadTransportGameObjectGrids(); assert(removed.loaded.empty());
}
'''
with tempfile.TemporaryDirectory(prefix="vcore-grid-transport-") as folder:
source, executable = Path(folder) / "test.cpp", Path(folder) / "test"
source.write_text(harness)
subprocess.run(["c++", "-std=c++17", str(source), "-o", str(executable)], check=True)
subprocess.run([str(executable)], check=True)
print("Core transport grid initialization regressions passed (no CPC dependency).")