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:
1 parent
01a9ba4b0b
commit
1164a72ea3
40 files changed
+4533
-163
No files matched your search
@@ -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).
|
||||
@@ -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
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).")
|
||||
@@ -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.')
|
||||
@@ -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).")
|
||||
Reference in new issue
Block a user