diff --git a/doc/clientless/lua-reload.md b/doc/clientless/lua-reload.md new file mode 100644 index 0000000000..c0bc43b560 --- /dev/null +++ b/doc/clientless/lua-reload.md @@ -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/.lua` oder +`adventure/profiles/generic.lua` sowie `adventure/overrides/.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). diff --git a/doc/clientless/ruttheran-portals.md b/doc/clientless/ruttheran-portals.md new file mode 100644 index 0000000000..a3cee439cd --- /dev/null +++ b/doc/clientless/ruttheran-portals.md @@ -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 ` 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. diff --git a/doc/clientless/same-map-portals.json b/doc/clientless/same-map-portals.json new file mode 100644 index 0000000000..11358ac40b --- /dev/null +++ b/doc/clientless/same-map-portals.json @@ -0,0 +1,1738 @@ +{ + "counts": { + "teleports": 316, + "same_map": 60, + "cross_map": 256, + "missing_dbc": 0, + "active_same_map": 2 + }, + "same_map": [ + { + "id": 527, + "name": "Darnassus Exit Target", + "map": 1, + "map_name": "Kalimdor", + "map_type": 0, + "source": { + "map": 1, + "x": 9947.48046875, + "y": 2630.0400390625, + "z": 1318.5999755859375, + "radius": 10.0 + }, + "target": { + "id": 527, + "name": "Darnassus Exit Target", + "map": 1, + "x": 8380.56, + "y": 998.536, + "z": 29.1294, + "o": 3.40339 + }, + "distance": 2603.800981290026, + "source_node": { + "key": "portal_at_527_source", + "enabled": 1, + "point": "portal_at_527_source", + "pointEnabled": 1, + "map": 1, + "x": 9947.48, + "y": 2630.04, + "z": 1318.6 + }, + "target_node": { + "key": "portal_at_527_target", + "enabled": 1, + "point": "portal_at_527_target", + "pointEnabled": 1, + "map": 1, + "x": 8380.56, + "y": 998.536, + "z": 29.1294 + }, + "edge": { + "from": "portal_at_527_source", + "to": "portal_at_527_target", + "enabled": 1, + "bidirectional": 0 + }, + "active": true + }, + { + "id": 542, + "name": "Darnassus Entrance Target", + "map": 1, + "map_name": "Kalimdor", + "map_type": 0, + "source": { + "map": 1, + "x": 8393.7998046875, + "y": 1003.9000244140625, + "z": 30.191200256347656, + "radius": 10.0 + }, + "target": { + "id": 542, + "name": "Darnassus Entrance Target", + "map": 1, + "x": 9946.25, + "y": 2612.97, + "z": 1316.49, + "o": 4.71239 + }, + "distance": 2579.490724441534, + "source_node": { + "key": "portal_at_542_source", + "enabled": 1, + "point": "portal_at_542_source", + "pointEnabled": 1, + "map": 1, + "x": 8393.8, + "y": 1003.9, + "z": 30.1912 + }, + "target_node": { + "key": "portal_at_542_target", + "enabled": 1, + "point": "portal_at_542_target", + "pointEnabled": 1, + "map": 1, + "x": 9946.25, + "y": 2612.97, + "z": 1316.49 + }, + "edge": { + "from": "portal_at_542_source", + "to": "portal_at_542_target", + "enabled": 1, + "bidirectional": 0 + }, + "active": true + }, + { + "id": 702, + "name": "Stormwind - Mage Exit Target", + "map": 0, + "map_name": "Eastern Kingdoms", + "map_type": 0, + "source": { + "map": 0, + "x": -9020.669921875, + "y": 890.10400390625, + "z": 34.0, + "radius": 0.0 + }, + "target": { + "id": 702, + "name": "Stormwind - Mage Exit Target", + "map": 0, + "x": -9014.94, + "y": 873.326, + "z": 148.616, + "o": 5.23599 + }, + "distance": 115.97913983028043, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 704, + "name": "Stormwind - Mage Entrance Target", + "map": 0, + "map_name": "Eastern Kingdoms", + "map_type": 0, + "source": { + "map": 0, + "x": -9017.3701171875, + "y": 877.2990112304688, + "z": 152.0, + "radius": 0.0 + }, + "target": { + "id": 704, + "name": "Stormwind - Mage Entrance Target", + "map": 0, + "x": -9016.97, + "y": 885.436, + "z": 29.6207, + "o": 5.41052 + }, + "distance": 122.65016815516077, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 943, + "name": "Thousand Needles - Test of Faith (Target)", + "map": 1, + "map_name": "Kalimdor", + "map_type": 0, + "source": { + "map": 1, + "x": -4950.740234375, + "y": -1595.1400146484375, + "z": -3.015700101852417, + "radius": 25.0 + }, + "target": { + "id": 943, + "name": "Thousand Needles - Test of Faith (Target)", + "map": 1, + "x": -5191.27, + "y": -2802.59, + "z": -7.21111, + "o": 5.67232 + }, + "distance": 1231.1813987960181, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 4233, + "name": "Eversong Woods - Duskwither UP Target", + "map": 530, + "map_name": "Outland", + "map_type": 0, + "source": { + "map": 530, + "x": 9336.2197265625, + "y": -7883.4599609375, + "z": 74.90950012207031, + "radius": 2.0 + }, + "target": { + "id": 4233, + "name": "Eversong Woods - Duskwither UP Target", + "map": 530, + "x": 9331.49, + "y": -7812.27, + "z": 136.569, + "o": 5.23599 + }, + "distance": 94.29885883103329, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 4267, + "name": "Eversong Woods - Duskwither DOWN Target", + "map": 530, + "map_name": "Outland", + "map_type": 0, + "source": { + "map": 530, + "x": 9285.9697265625, + "y": -7916.009765625, + "z": 176.1300048828125, + "radius": 2.0 + }, + "target": { + "id": 4267, + "name": "Eversong Woods - Duskwither DOWN Target", + "map": 530, + "x": 9334.03, + "y": -7880.02, + "z": 74.9095, + "o": 2.35619 + }, + "distance": 117.68875783761676, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 4311, + "name": "CoT - Hyjal - Human Base - Teleport Target", + "map": 534, + "map_name": "The Battle for Mount Hyjal", + "map_type": 2, + "source": { + "map": 534, + "x": 4162.02001953125, + "y": -4227.06982421875, + "z": 869.1849975585938, + "radius": 0.0 + }, + "target": { + "id": 4311, + "name": "CoT - Hyjal - Human Base - Teleport Target", + "map": 534, + "x": 5066.79, + "y": -1791.9, + "z": 1321.65, + "o": 2.35619 + }, + "distance": 2636.927258908994, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 4312, + "name": "CoT - Hyjal - Orc Base - Teleport Target", + "map": 534, + "map_name": "The Battle for Mount Hyjal", + "map_type": 2, + "source": { + "map": 534, + "x": 4284.14990234375, + "y": -4138.85009765625, + "z": 866.8109741210938, + "radius": 0.0 + }, + "target": { + "id": 4312, + "name": "CoT - Hyjal - Orc Base - Teleport Target", + "map": 534, + "x": 5499.96, + "y": -2756.8, + "z": 1488.96, + "o": 1.39626 + }, + "distance": 1943.019834277721, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 4313, + "name": "CoT - Hyjal - Night Elf Base - Teleport Target", + "map": 534, + "map_name": "The Battle for Mount Hyjal", + "map_type": 2, + "source": { + "map": 534, + "x": 4203.64013671875, + "y": -4103.240234375, + "z": 878.3629760742188, + "radius": 0.0 + }, + "target": { + "id": 4313, + "name": "CoT - Hyjal - Night Elf Base - Teleport Target", + "map": 534, + "x": 5163.02, + "y": -3428.31, + "z": 1627.61, + "o": 0.785398 + }, + "distance": 1391.8734303824767, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 4561, + "name": "Invasion Point, Cataclysm (Return Point)", + "map": 530, + "map_name": "Outland", + "map_type": 0, + "source": { + "map": 530, + "x": -2829.780029296875, + "y": 1953.760009765625, + "z": 202.4600067138672, + "radius": 2.299999952316284 + }, + "target": { + "id": 4561, + "name": "Invasion Point, Cataclysm (Return Point)", + "map": 530, + "x": -3278.63, + "y": 2831.31, + "z": 123.01, + "o": 1.56 + }, + "distance": 988.8744031106181, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 4562, + "name": "Invasion Point, Cataclysm (Return Point)", + "map": 530, + "map_name": "Outland", + "map_type": 0, + "source": { + "map": 530, + "x": -2745.239990234375, + "y": 2730.419921875, + "z": 127.7760009765625, + "radius": 2.299999952316284 + }, + "target": { + "id": 4562, + "name": "Invasion Point, Cataclysm (Return Point)", + "map": 530, + "x": -3278.63, + "y": 2831.31, + "z": 123.01, + "o": 1.56 + }, + "distance": 542.8686997305617, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 4917, + "name": "Arena - Nagrand - Teleport Target - Game On", + "map": 559, + "map_name": "Nagrand Arena", + "map_type": 4, + "source": { + "map": 559, + "x": 4054.14990234375, + "y": 2923.699951171875, + "z": -40.0, + "radius": 0.0 + }, + "target": { + "id": 4917, + "name": "Arena - Nagrand - Teleport Target - Game On", + "map": 559, + "x": 4054.57, + "y": 2923.23, + "z": 13.8179, + "o": 0 + }, + "distance": 53.821591360296416, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 4919, + "name": "Arena - Bladesedge - Teleport Target - Game On", + "map": 562, + "map_name": "Blade's Edge Arena", + "map_type": 4, + "source": { + "map": 562, + "x": 6188.009765625, + "y": 320.0, + "z": 20.0, + "radius": 0.0 + }, + "target": { + "id": 4919, + "name": "Arena - Bladesedge - Teleport Target - Game On", + "map": 562, + "x": 6238.89, + "y": 264.155, + "z": 0.894302, + "o": 0 + }, + "distance": 77.92618283428345, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 4921, + "name": "Arena - Bladesedge - Teleport Target - Game On", + "map": 562, + "map_name": "Blade's Edge Arena", + "map_type": 4, + "source": { + "map": 562, + "x": 6288.22021484375, + "y": 210.0, + "z": 20.0, + "radius": 0.0 + }, + "target": { + "id": 4921, + "name": "Arena - Bladesedge - Teleport Target - Game On", + "map": 562, + "x": 6238.89, + "y": 264.155, + "z": 0.894302, + "o": 0 + }, + "distance": 75.70509769888486, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 4922, + "name": "Arena - Bladesedge - Teleport Target - Game On", + "map": 562, + "map_name": "Blade's Edge Arena", + "map_type": 4, + "source": { + "map": 562, + "x": 6214.8701171875, + "y": 200.26100158691406, + "z": 20.0, + "radius": 0.0 + }, + "target": { + "id": 4922, + "name": "Arena - Bladesedge - Teleport Target - Game On", + "map": 562, + "x": 6238.89, + "y": 264.155, + "z": 0.894302, + "o": 0 + }, + "distance": 70.88318206461162, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 4923, + "name": "Arena - Bladesedge - Teleport Target - Game On", + "map": 562, + "map_name": "Blade's Edge Arena", + "map_type": 4, + "source": { + "map": 562, + "x": 6187.77001953125, + "y": 196.8800048828125, + "z": 20.0, + "radius": 0.0 + }, + "target": { + "id": 4923, + "name": "Arena - Bladesedge - Teleport Target - Game On", + "map": 562, + "x": 6238.89, + "y": 264.155, + "z": 0.894302, + "o": 0 + }, + "distance": 86.62681494323924, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 4924, + "name": "Arena - Bladesedge - Teleport Target - Game On", + "map": 562, + "map_name": "Blade's Edge Arena", + "map_type": 4, + "source": { + "map": 562, + "x": 6264.759765625, + "y": 329.75201416015625, + "z": 3.414210081100464, + "radius": 0.0 + }, + "target": { + "id": 4924, + "name": "Arena - Bladesedge - Teleport Target - Game On", + "map": 562, + "x": 6238.89, + "y": 264.155, + "z": 0.894302, + "o": 0 + }, + "distance": 70.5589326517724, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 4925, + "name": "Arena - Bladesedge - Teleport Target - Game On", + "map": 562, + "map_name": "Blade's Edge Arena", + "map_type": 4, + "source": { + "map": 562, + "x": 6290.77001953125, + "y": 333.4119873046875, + "z": 20.0, + "radius": 0.0 + }, + "target": { + "id": 4925, + "name": "Arena - Bladesedge - Teleport Target - Game On", + "map": 562, + "x": 6238.89, + "y": 264.155, + "z": 0.894302, + "o": 0 + }, + "distance": 88.61768679643868, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 4927, + "name": "Arena - Undercity - Teleport Target - Game On", + "map": 572, + "map_name": "Ruins of Lordaeron", + "map_type": 4, + "source": { + "map": 572, + "x": 1200.3299560546875, + "y": 1682.0899658203125, + "z": 33.07809829711914, + "radius": 0.0 + }, + "target": { + "id": 4927, + "name": "Arena - Undercity - Teleport Target - Game On", + "map": 572, + "x": 1286.57, + "y": 1667.4, + "z": 39.602, + "o": 0 + }, + "distance": 87.72514787060902, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 4928, + "name": "Arena - Undercity - Teleport Target - Game On", + "map": 572, + "map_name": "Ruins of Lordaeron", + "map_type": 4, + "source": { + "map": 572, + "x": 1223.6099853515625, + "y": 1630.81005859375, + "z": 32.60710144042969, + "radius": 0.0 + }, + "target": { + "id": 4928, + "name": "Arena - Undercity - Teleport Target - Game On", + "map": 572, + "x": 1286.57, + "y": 1667.4, + "z": 39.602, + "o": 0 + }, + "distance": 73.15542264591839, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 4929, + "name": "Arena - Undercity - Teleport Target - Game On", + "map": 572, + "map_name": "Ruins of Lordaeron", + "map_type": 4, + "source": { + "map": 572, + "x": 1359.1800537109375, + "y": 1692.25, + "z": 37.070499420166016, + "radius": 0.0 + }, + "target": { + "id": 4929, + "name": "Arena - Undercity - Teleport Target - Game On", + "map": 572, + "x": 1286.57, + "y": 1667.4, + "z": 39.602, + "o": 0 + }, + "distance": 76.78639785203453, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 4930, + "name": "Arena - Undercity - Teleport Target - Game On", + "map": 572, + "map_name": "Ruins of Lordaeron", + "map_type": 4, + "source": { + "map": 572, + "x": 1371.510009765625, + "y": 1649.0799560546875, + "z": 34.5088996887207, + "radius": 0.0 + }, + "target": { + "id": 4930, + "name": "Arena - Undercity - Teleport Target - Game On", + "map": 572, + "x": 1286.57, + "y": 1667.4, + "z": 39.602, + "o": 0 + }, + "distance": 87.04233992674725, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 4931, + "name": "Arena - Undercity - Teleport Target - Game On", + "map": 572, + "map_name": "Ruins of Lordaeron", + "map_type": 4, + "source": { + "map": 572, + "x": 1299.760009765625, + "y": 1554.739990234375, + "z": 40.0, + "radius": 0.0 + }, + "target": { + "id": 4931, + "name": "Arena - Undercity - Teleport Target - Game On", + "map": 572, + "x": 1286.57, + "y": 1667.4, + "z": 39.602, + "o": 0 + }, + "distance": 113.43021009417211, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 4932, + "name": "Arena - Undercity - Teleport Target - Game On", + "map": 572, + "map_name": "Ruins of Lordaeron", + "map_type": 4, + "source": { + "map": 572, + "x": 1274.510009765625, + "y": 1774.6800537109375, + "z": 27.03660011291504, + "radius": 0.0 + }, + "target": { + "id": 4932, + "name": "Arena - Undercity - Teleport Target - Game On", + "map": 572, + "x": 1286.57, + "y": 1667.4, + "z": 39.602, + "o": 0 + }, + "distance": 108.68460131498477, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 4933, + "name": "Arena - Undercity - Teleport Target - Game On", + "map": 572, + "map_name": "Ruins of Lordaeron", + "map_type": 4, + "source": { + "map": 572, + "x": 1246.06005859375, + "y": 1743.6199951171875, + "z": 40.0, + "radius": 0.0 + }, + "target": { + "id": 4933, + "name": "Arena - Undercity - Teleport Target - Game On", + "map": 572, + "x": 1286.57, + "y": 1667.4, + "z": 39.602, + "o": 0 + }, + "distance": 86.31744558547754, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 4934, + "name": "Arena - Undercity - Teleport Target - Game On", + "map": 572, + "map_name": "Ruins of Lordaeron", + "map_type": 4, + "source": { + "map": 572, + "x": 1305.43994140625, + "y": 1754.1400146484375, + "z": 40.0, + "radius": 0.0 + }, + "target": { + "id": 4934, + "name": "Arena - Undercity - Teleport Target - Game On", + "map": 572, + "x": 1286.57, + "y": 1667.4, + "z": 39.602, + "o": 0 + }, + "distance": 88.76972025351013, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 4935, + "name": "Arena - Undercity - Teleport Target - Game On", + "map": 572, + "map_name": "Ruins of Lordaeron", + "map_type": 4, + "source": { + "map": 572, + "x": 1321.6199951171875, + "y": 1586.97998046875, + "z": 40.0, + "radius": 0.0 + }, + "target": { + "id": 4935, + "name": "Arena - Undercity - Teleport Target - Game On", + "map": 572, + "x": 1286.57, + "y": 1667.4, + "z": 39.602, + "o": 0 + }, + "distance": 87.72707736566583, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 4936, + "name": "Arena - Undercity - Teleport Target - Game On", + "map": 572, + "map_name": "Ruins of Lordaeron", + "map_type": 4, + "source": { + "map": 572, + "x": 1269.1199951171875, + "y": 1579.989990234375, + "z": 40.0, + "radius": 0.0 + }, + "target": { + "id": 4936, + "name": "Arena - Undercity - Teleport Target - Game On", + "map": 572, + "x": 1286.57, + "y": 1667.4, + "z": 39.602, + "o": 0 + }, + "distance": 89.13568803592, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 4941, + "name": "Arena - Undercity - Teleport Target - Game On", + "map": 572, + "map_name": "Ruins of Lordaeron", + "map_type": 4, + "source": { + "map": 572, + "x": 1285.3599853515625, + "y": 1667.6800537109375, + "z": 10.0, + "radius": 0.0 + }, + "target": { + "id": 4941, + "name": "Arena - Undercity - Teleport Target - Game On", + "map": 572, + "x": 1286.57, + "y": 1667.4, + "z": 39.602, + "o": 0 + }, + "distance": 29.62804363319392, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 4944, + "name": "Arena - Bladesedge - Teleport Target - Game On", + "map": 562, + "map_name": "Blade's Edge Arena", + "map_type": 4, + "source": { + "map": 562, + "x": 6237.83984375, + "y": 261.3630065917969, + "z": -20.0, + "radius": 0.0 + }, + "target": { + "id": 4944, + "name": "Arena - Bladesedge - Teleport Target - Game On", + "map": 562, + "x": 6238.89, + "y": 264.155, + "z": 0.894302, + "o": 0 + }, + "distance": 21.10615813946414, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 5127, + "name": "Ebon Hold (E.K.) Top Return Target", + "map": 0, + "map_name": "Eastern Kingdoms", + "map_type": 0, + "source": { + "map": 0, + "x": 2479.340087890625, + "y": -5495.72998046875, + "z": 421.84100341796875, + "radius": 4.0 + }, + "target": { + "id": 5127, + "name": "Ebon Hold (E.K.) Top Return Target", + "map": 0, + "x": 2462.76, + "y": -5598.19, + "z": 550.22, + "o": 3.66519 + }, + "distance": 165.08822392955256, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 5128, + "name": "Ebon Hold (E.K.) Top West Target", + "map": 0, + "map_name": "Eastern Kingdoms", + "map_type": 0, + "source": { + "map": 0, + "x": 2469.2900390625, + "y": -5545.83984375, + "z": 546.1010131835938, + "radius": 4.0 + }, + "target": { + "id": 5128, + "name": "Ebon Hold (E.K.) Top West Target", + "map": 0, + "x": 2472.67, + "y": -5530.1, + "z": 420.642, + "o": 4.50295 + }, + "distance": 126.48767056995943, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 5129, + "name": "Ebon Hold (E.K.) Top Return Target", + "map": 0, + "map_name": "Eastern Kingdoms", + "map_type": 0, + "source": { + "map": 0, + "x": 2362.550048828125, + "y": -5574.5400390625, + "z": 421.8380126953125, + "radius": 4.0 + }, + "target": { + "id": 5129, + "name": "Ebon Hold (E.K.) Top Return Target", + "map": 0, + "x": 2462.76, + "y": -5598.19, + "z": 550.22, + "o": 3.66519 + }, + "distance": 164.570014372351, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 5130, + "name": "Ebon Hold (E.K.) Top South Target", + "map": 0, + "map_name": "Eastern Kingdoms", + "map_type": 0, + "source": { + "map": 0, + "x": 2412.679931640625, + "y": -5584.64990234375, + "z": 546.1010131835938, + "radius": 4.0 + }, + "target": { + "id": 5130, + "name": "Ebon Hold (E.K.) Top South Target", + "map": 0, + "x": 2397.37, + "y": -5581.06, + "z": 420.643, + "o": 6.07375 + }, + "distance": 126.43968711465978, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 5131, + "name": "Ebon Hold (E.K.) Top Return Target", + "map": 0, + "map_name": "Eastern Kingdoms", + "map_type": 0, + "source": { + "map": 0, + "x": 2558.3701171875, + "y": -5612.56005859375, + "z": 421.8349914550781, + "radius": 4.0 + }, + "target": { + "id": 5131, + "name": "Ebon Hold (E.K.) Top Return Target", + "map": 0, + "x": 2462.76, + "y": -5598.19, + "z": 550.22, + "o": 3.66519 + }, + "distance": 160.71870927703137, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 5132, + "name": "Ebon Hold (E.K.) Top North Target", + "map": 0, + "map_name": "Eastern Kingdoms", + "map_type": 0, + "source": { + "map": 0, + "x": 2508.139892578125, + "y": -5602.5, + "z": 546.1010131835938, + "radius": 4.0 + }, + "target": { + "id": 5132, + "name": "Ebon Hold (E.K.) Top North Target", + "map": 0, + "x": 2522.45, + "y": -5605.7, + "z": 420.642, + "o": 2.94961 + }, + "distance": 126.31303639540437, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 5133, + "name": "Ebon Hold (E.K.) Top Return Target", + "map": 0, + "map_name": "Eastern Kingdoms", + "map_type": 0, + "source": { + "map": 0, + "x": 2441.3701171875, + "y": -5691.419921875, + "z": 421.8340148925781, + "radius": 4.0 + }, + "target": { + "id": 5133, + "name": "Ebon Hold (E.K.) Top Return Target", + "map": 0, + "x": 2462.76, + "y": -5598.19, + "z": 550.22, + "o": 3.66519 + }, + "distance": 160.10092626700924, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 5134, + "name": "Ebon Hold (E.K.) Top East Target", + "map": 0, + "map_name": "Eastern Kingdoms", + "map_type": 0, + "source": { + "map": 0, + "x": 2451.5, + "y": -5641.27001953125, + "z": 546.1010131835938, + "radius": 4.0 + }, + "target": { + "id": 5134, + "name": "Ebon Hold (E.K.) Top East Target", + "map": 0, + "x": 2448.02, + "y": -5655.82, + "z": 420.643, + "o": 1.3439 + }, + "distance": 126.34684564173254, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 5135, + "name": "Ebon Hold (E.K.) Middle -> Bottom Target", + "map": 0, + "map_name": "Eastern Kingdoms", + "map_type": 0, + "source": { + "map": 0, + "x": 2427.669921875, + "y": -5548.64013671875, + "z": 542.0910034179688, + "radius": 4.0 + }, + "target": { + "id": 5135, + "name": "Ebon Hold (E.K.) Middle -> Bottom Target", + "map": 0, + "x": 2435.77, + "y": -5610.34, + "z": 366.82, + "o": 0.610865 + }, + "distance": 185.9903466142737, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 5136, + "name": "Ebon Hold (E.K.) Middle -> Bottom Target", + "map": 0, + "map_name": "Eastern Kingdoms", + "map_type": 0, + "source": { + "map": 0, + "x": 2505.389892578125, + "y": -5563.89990234375, + "z": 542.0889892578125, + "radius": 2.0 + }, + "target": { + "id": 5136, + "name": "Ebon Hold (E.K.) Middle -> Bottom Target", + "map": 0, + "x": 2435.77, + "y": -5610.34, + "z": 366.82, + "o": 0.610865 + }, + "distance": 194.22366155637914, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 5137, + "name": "Ebon Hold (E.K.) Bottom -> Middle Target", + "map": 0, + "map_name": "Eastern Kingdoms", + "map_type": 0, + "source": { + "map": 0, + "x": 2419.030029296875, + "y": -5622.169921875, + "z": 542.0709838867188, + "radius": 3.0 + }, + "target": { + "id": 5137, + "name": "Ebon Hold (E.K.) Bottom -> Middle Target", + "map": 0, + "x": 2472.9, + "y": -5530.64, + "z": 420.643, + "o": 4.45059 + }, + "distance": 161.3209707781116, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 5138, + "name": "Ebon Hold (E.K.) Bottom -> Ground Target", + "map": 0, + "map_name": "Eastern Kingdoms", + "map_type": 0, + "source": { + "map": 0, + "x": 2486.989990234375, + "y": -5636.419921875, + "z": 542.135986328125, + "radius": 3.0 + }, + "target": { + "id": 5138, + "name": "Ebon Hold (E.K.) Bottom -> Ground Target", + "map": 0, + "x": 2415.16, + "y": -5733.04, + "z": 153.922, + "o": 3.64774 + }, + "distance": 406.4542854672898, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 5273, + "name": "Dalaran Well teleporter", + "map": 571, + "map_name": "Northrend", + "map_type": 0, + "source": { + "map": 571, + "x": 5886.27001953125, + "y": 651.448974609375, + "z": 640.0, + "radius": 6.0 + }, + "target": { + "id": 5273, + "name": "Dalaran Well teleporter", + "map": 571, + "x": 5878.56, + "y": 666.423, + "z": 615.294, + "o": 4 + }, + "distance": 29.90070690755209, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 5326, + "name": "Arena - Dalaran Arena - Teleport Target", + "map": 617, + "map_name": "Dalaran Sewers", + "map_type": 4, + "source": { + "map": 617, + "x": 1330.0, + "y": 800.0, + "z": -10.0, + "radius": 0.0 + }, + "target": { + "id": 5326, + "name": "Arena - Dalaran Arena - Teleport Target", + "map": 617, + "x": 1291.57, + "y": 792.965, + "z": 8.11463, + "o": 0 + }, + "distance": 43.06385891948032, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 5328, + "name": "Arena - Dalaran Arena - Teleport Target", + "map": 617, + "map_name": "Dalaran Sewers", + "map_type": 4, + "source": { + "map": 617, + "x": 1289.719970703125, + "y": 736.6129760742188, + "z": 3.1596999168395996, + "radius": 0.0 + }, + "target": { + "id": 5328, + "name": "Arena - Dalaran Arena - Teleport Target", + "map": 617, + "x": 1291.57, + "y": 792.965, + "z": 8.11463, + "o": 0 + }, + "distance": 56.59968675761492, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 5329, + "name": "Arena - Dalaran Arena - Teleport Target", + "map": 617, + "map_name": "Dalaran Sewers", + "map_type": 4, + "source": { + "map": 617, + "x": 1292.050048828125, + "y": 845.2730102539062, + "z": 5.630660057067871, + "radius": 0.0 + }, + "target": { + "id": 5329, + "name": "Arena - Dalaran Arena - Teleport Target", + "map": 617, + "x": 1291.57, + "y": 792.965, + "z": 8.11463, + "o": 0 + }, + "distance": 52.36915590571928, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 5330, + "name": "Arena - Dalaran Arena - Teleport Target", + "map": 617, + "map_name": "Dalaran Sewers", + "map_type": 4, + "source": { + "map": 617, + "x": 1238.0799560546875, + "y": 780.1060180664062, + "z": 3.1597299575805664, + "radius": 0.0 + }, + "target": { + "id": 5330, + "name": "Arena - Dalaran Arena - Teleport Target", + "map": 617, + "x": 1291.57, + "y": 792.965, + "z": 8.11463, + "o": 0 + }, + "distance": 55.236665830499895, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 5331, + "name": "Arena - Dalaran Arena - Teleport Target", + "map": 617, + "map_name": "Dalaran Sewers", + "map_type": 4, + "source": { + "map": 617, + "x": 1348.5, + "y": 792.8280029296875, + "z": 3.823050022125244, + "radius": 0.0 + }, + "target": { + "id": 5331, + "name": "Arena - Dalaran Arena - Teleport Target", + "map": 617, + "x": 1291.57, + "y": 792.965, + "z": 8.11463, + "o": 0 + }, + "distance": 57.09169227570485, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 5447, + "name": "Arena - Orgrimmar - Teleport Target 3", + "map": 618, + "map_name": "The Ring of Valor", + "map_type": 4, + "source": { + "map": 618, + "x": 801.6630249023438, + "y": -278.85699462890625, + "z": -24.0, + "radius": 0.0 + }, + "target": { + "id": 5447, + "name": "Arena - Orgrimmar - Teleport Target 3", + "map": 618, + "x": 763.365, + "y": -284.29, + "z": 28.2767, + "o": 0 + }, + "distance": 65.0316047908467, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 5740, + "name": "Halls of Reflection (The Impenetrable Door)", + "map": 668, + "map_name": "Halls of Reflection", + "map_type": 1, + "source": { + "map": 668, + "x": 5361.10009765625, + "y": 2061.02001953125, + "z": 707.6950073242188, + "radius": 0.0 + }, + "target": { + "id": 5740, + "name": "Halls of Reflection (The Impenetrable Door)", + "map": 668, + "x": 5354.01, + "y": 2053.53, + "z": 707.695, + "o": 0.785398 + }, + "distance": 10.313577330573313, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 5879, + "name": "Firelands Forgeworks (Exit)", + "map": 1, + "map_name": "Kalimdor", + "map_type": 0, + "source": { + "map": 1, + "x": 5042.169921875, + "y": -2026.510009765625, + "z": 1148.97998046875, + "radius": 5.05079984664917 + }, + "target": { + "id": 5879, + "name": "Firelands Forgeworks (Exit)", + "map": 1, + "x": 5036.41, + "y": -2045.99, + "z": 1368.39, + "o": 5.2961 + }, + "distance": 220.3483682495539, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 5880, + "name": "Firelands Forgeworks (Enterance)", + "map": 1, + "map_name": "Kalimdor", + "map_type": 0, + "source": { + "map": 1, + "x": 5030.83984375, + "y": -2036.300048828125, + "z": 1378.0899658203125, + "radius": 8.019330024719238 + }, + "target": { + "id": 5880, + "name": "Firelands Forgeworks (Enterance)", + "map": 1, + "x": 5029.45, + "y": -2029.36, + "z": 1148.97, + "o": 0.214575 + }, + "distance": 229.22926227008483, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 5893, + "name": "Hyjal - Teleport from Seat of the Prophets", + "map": 1, + "map_name": "Kalimdor", + "map_type": 0, + "source": { + "map": 1, + "x": 4320.919921875, + "y": -3288.199951171875, + "z": 1036.050048828125, + "radius": 3.7845399379730225 + }, + "target": { + "id": 5893, + "name": "Hyjal - Teleport from Seat of the Prophets", + "map": 1, + "x": 3948.62, + "y": -2818.22, + "z": 618.747, + "o": 0 + }, + "distance": 730.4999800772741, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 5895, + "name": "Hyjal - Teleport From Sulfuron Spire", + "map": 1, + "map_name": "Kalimdor", + "map_type": 0, + "source": { + "map": 1, + "x": 3943.89990234375, + "y": -2807.489990234375, + "z": 618.7470092773438, + "radius": 3.6830599308013916 + }, + "target": { + "id": 5895, + "name": "Hyjal - Teleport From Sulfuron Spire", + "map": 1, + "x": 4316.05, + "y": -3282.51, + "z": 1035.53, + "o": 0 + }, + "distance": 733.3810511725899, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 5926, + "name": "The Lost Isles - Volcanoth's Lair - Teleport Target - Phase 8+", + "map": 659, + "map_name": "Lost Isles Volcano Eruption", + "map_type": 0, + "source": { + "map": 659, + "x": 1429.989990234375, + "y": 1233.8599853515625, + "z": 124.60399627685547, + "radius": 0.0 + }, + "target": { + "id": 5926, + "name": "The Lost Isles - Volcanoth's Lair - Teleport Target - Phase 8+", + "map": 659, + "x": 1172.59, + "y": 1094.79, + "z": 119.232, + "o": 1.5708 + }, + "distance": 292.61591573654835, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 5931, + "name": "The Crucible of Flame (Enterance)", + "map": 1, + "map_name": "Kalimdor", + "map_type": 0, + "source": { + "map": 1, + "x": 4664.0, + "y": -3686.81005859375, + "z": 956.6339721679688, + "radius": 6.782060146331787 + }, + "target": { + "id": 5931, + "name": "The Crucible of Flame (Enterance)", + "map": 1, + "x": 4679.1, + "y": -3675.53, + "z": 696.452, + "o": 1.3644 + }, + "distance": 260.8637735736645, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 5937, + "name": "The Crucible of Flame (Exit)", + "map": 1, + "map_name": "Kalimdor", + "map_type": 0, + "source": { + "map": 1, + "x": 4678.14990234375, + "y": -3682.77001953125, + "z": 696.4450073242188, + "radius": 2.2342700958251953 + }, + "target": { + "id": 5937, + "name": "The Crucible of Flame (Exit)", + "map": 1, + "x": 4653.99, + "y": -3688.44, + "z": 955.265, + "o": 2.38149 + }, + "distance": 260.00699638368775, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 6321, + "name": "Badlands 4.x - Cliff Teleport Target", + "map": 0, + "map_name": "Eastern Kingdoms", + "map_type": 0, + "source": { + "map": 0, + "x": -6518.68017578125, + "y": -4604.419921875, + "z": 29.23349952697754, + "radius": 1.7877600193023682 + }, + "target": { + "id": 6321, + "name": "Badlands 4.x - Cliff Teleport Target", + "map": 0, + "x": -6547.88, + "y": -4443.24, + "z": 332.079, + "o": 0.663225 + }, + "distance": 344.30654090039087, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + }, + { + "id": 6322, + "name": "Badlands 4.x - Dock Teleport Target", + "map": 0, + "map_name": "Eastern Kingdoms", + "map_type": 0, + "source": { + "map": 0, + "x": -6551.27978515625, + "y": -4446.4599609375, + "z": 332.8290100097656, + "radius": 1.7877600193023682 + }, + "target": { + "id": 6322, + "name": "Badlands 4.x - Dock Teleport Target", + "map": 0, + "x": -6519.82, + "y": -4608.52, + "z": 28.4842, + "o": 4.36332 + }, + "distance": 346.2353790745494, + "source_node": null, + "target_node": null, + "edge": null, + "active": false + } + ], + "missing_dbc": [] +} diff --git a/doc/clientless/same-map-portals.md b/doc/clientless/same-map-portals.md new file mode 100644 index 0000000000..69aea98667 --- /dev/null +++ b/doc/clientless/same-map-portals.md @@ -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). diff --git a/doc/clientless/ship-stormwind-ruttheran.md b/doc/clientless/ship-stormwind-ruttheran.md new file mode 100644 index 0000000000..ef2fbdfccc --- /dev/null +++ b/doc/clientless/ship-stormwind-ruttheran.md @@ -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. diff --git a/doc/clientless/transport-grid-lifecycle.md b/doc/clientless/transport-grid-lifecycle.md new file mode 100644 index 0000000000..87a861dbb7 --- /dev/null +++ b/doc/clientless/transport-grid-lifecycle.md @@ -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. diff --git a/doc/clientless/transport-tools.md b/doc/clientless/transport-tools.md new file mode 100644 index 0000000000..6861869ded --- /dev/null +++ b/doc/clientless/transport-tools.md @@ -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 ` und `remove ` 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. diff --git a/sql/custom/world/.gitignore b/sql/custom/world/.gitignore index d1b811b7de..0fe11aa1f5 100644 --- a/sql/custom/world/.gitignore +++ b/sql/custom/world/.gitignore @@ -1 +1,3 @@ *.sql +!2026_10_25_00_clientless_transport.sql +!2026_10_26_00_clientless_disable_taxi_585.sql diff --git a/sql/custom/world/2026_10_25_00_clientless_transport.sql b/sql/custom/world/2026_10_25_00_clientless_transport.sql new file mode 100644 index 0000000000..ac6fbcb5c5 --- /dev/null +++ b/sql/custom/world/2026_10_25_00_clientless_transport.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; diff --git a/sql/custom/world/2026_10_26_00_clientless_disable_taxi_585.sql b/sql/custom/world/2026_10_26_00_clientless_disable_taxi_585.sql new file mode 100644 index 0000000000..805c176793 --- /dev/null +++ b/sql/custom/world/2026_10_26_00_clientless_disable_taxi_585.sql @@ -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; diff --git a/src/server/game/Chat/Chat.cpp b/src/server/game/Chat/Chat.cpp index 46a7cf7704..b2af0f0ede 100644 --- a/src/server/game/Chat/Chat.cpp +++ b/src/server/game/Chat/Chat.cpp @@ -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& 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; diff --git a/src/server/game/Chat/Chat.h b/src/server/game/Chat/Chat.h index 47f640542b..aa17f93b65 100644 --- a/src/server/game/Chat/Chat.h +++ b/src/server/game/Chat/Chat.h @@ -147,6 +147,7 @@ class TC_GAME_API ChatHandler // common global flag bool sentErrorMessage; + bool _logClientlessCommand = false; }; class TC_GAME_API CliHandler : public ChatHandler diff --git a/src/server/game/Entities/GameObject/GameObject.cpp b/src/server/game/Entities/GameObject/GameObject.cpp index fc11874e0f..3574d1f3b3 100644 --- a/src/server/game/Entities/GameObject/GameObject.cpp +++ b/src/server/game/Entities/GameObject/GameObject.cpp @@ -49,6 +49,7 @@ #include #include #include +#include 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::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 const* GameObject::GetPauseTimes() const { if (GameObjectType::Transport const* transport = dynamic_cast(m_goTypeImpl.get())) diff --git a/src/server/game/Entities/GameObject/GameObject.h b/src/server/game/Entities/GameObject/GameObject.h index 8ebaa3b7ee..71983c152b 100644 --- a/src/server/game/Entities/GameObject/GameObject.h +++ b/src/server/game/Entities/GameObject/GameObject.h @@ -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 static void SetGoArtKit(uint8 artkit, GameObject* go, ObjectGuid::LowType lowguid = 0); std::vector const* GetPauseTimes() const; + bool GetTransportDockState(TransportDockState& state) const; void SetPathProgressForClient(float progress); void PlayAnimKit(int32 animKit); diff --git a/src/server/game/Entities/Transport/Transport.cpp b/src/server/game/Entities/Transport/Transport.cpp index d3cb9502d0..5b6da43789 100644 --- a/src/server/game/Entities/Transport/Transport.cpp +++ b/src/server/game/Entities/Transport/Transport.cpp @@ -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()) diff --git a/src/server/game/Entities/Transport/Transport.h b/src/server/game/Entities/Transport/Transport.h index 83a8a29b16..80fb270224 100644 --- a/src/server/game/Entities/Transport/Transport.h +++ b/src/server/game/Entities/Transport/Transport.h @@ -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); diff --git a/src/server/game/Globals/ObjectMgr.cpp b/src/server/game/Globals/ObjectMgr.cpp index 830493b4ee..e60473b28b 100644 --- a/src/server/game/Globals/ObjectMgr.cpp +++ b/src/server/game/Globals/ObjectMgr.cpp @@ -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()); diff --git a/src/server/game/Globals/ObjectMgr.h b/src/server/game/Globals/ObjectMgr.h index 9d5421d757..c90ec0f834 100644 --- a/src/server/game/Globals/ObjectMgr.h +++ b/src/server/game/Globals/ObjectMgr.h @@ -1392,6 +1392,11 @@ class TC_GAME_API ObjectMgr return &itr->second; } GameObjectDataContainer const& GetAllGameObjectData() const { return _gameObjectDataStore; } + std::vector 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> _transportGameObjectSpawns; GameObjectLocaleContainer _gameObjectLocaleStore; GameObjectTemplateContainer _gameObjectTemplateStore; GameObjectTemplateAddonContainer _gameObjectTemplateAddonStore; diff --git a/src/server/game/Handlers/MovementHandler.cpp b/src/server/game/Handlers/MovementHandler.cpp index 15ad35198c..6b0d4d20e2 100644 --- a/src/server/game/Handlers/MovementHandler.cpp +++ b/src/server/game/Handlers/MovementHandler.cpp @@ -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(); diff --git a/src/server/game/Maps/Map.cpp b/src/server/game/Maps/Map.cpp index b079ca7b40..24a66c30d9 100644 --- a/src/server/game/Maps/Map.cpp +++ b/src/server/game/Maps/Map.cpp @@ -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(); diff --git a/src/server/game/Maps/Map.h b/src/server/game/Maps/Map.h index 301056c4af..75db509f72 100644 --- a/src/server/game/Maps/Map.h +++ b/src/server/game/Maps/Map.h @@ -218,6 +218,7 @@ class TC_GAME_API Map : public GridRefManager 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); diff --git a/src/server/game/Maps/MapManager.cpp b/src/server/game/Maps/MapManager.cpp index a06574b3dc..b40a2ec9e7 100644 --- a/src/server/game/Maps/MapManager.cpp +++ b/src/server/game/Maps/MapManager.cpp @@ -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; } diff --git a/src/server/game/Server/WorldSession.h b/src/server/game/Server/WorldSession.h index ffb393ffd9..d003349d7f 100644 --- a/src/server/game/Server/WorldSession.h +++ b/src/server/game/Server/WorldSession.h @@ -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; diff --git a/src/server/game/World/ClientlessActionExecutorMgr.cpp b/src/server/game/World/ClientlessActionExecutorMgr.cpp index 0fa228b534..83ecb50785 100644 --- a/src/server/game/World/ClientlessActionExecutorMgr.cpp +++ b/src/server/game/World/ClientlessActionExecutorMgr.cpp @@ -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::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::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(); diff --git a/src/server/game/World/ClientlessActionExecutorMgr.h b/src/server/game/World/ClientlessActionExecutorMgr.h index 4c119689cf..620371562d 100644 --- a/src/server/game/World/ClientlessActionExecutorMgr.h +++ b/src/server/game/World/ClientlessActionExecutorMgr.h @@ -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 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); diff --git a/src/server/game/World/ClientlessLuaEngine.cpp b/src/server/game/World/ClientlessLuaEngine.cpp index 1763443556..d602cb82a8 100644 --- a/src/server/game/World/ClientlessLuaEngine.cpp +++ b/src/server/game/World/ClientlessLuaEngine.cpp @@ -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 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 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) diff --git a/src/server/game/World/ClientlessLuaEngine.h b/src/server/game/World/ClientlessLuaEngine.h index 4bae645b5d..deba3812ba 100644 --- a/src/server/game/World/ClientlessLuaEngine.h +++ b/src/server/game/World/ClientlessLuaEngine.h @@ -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; diff --git a/src/server/game/World/ClientlessPlayerMgr.cpp b/src/server/game/World/ClientlessPlayerMgr.cpp index a0eb1b3912..9ffa3b1732 100644 --- a/src/server/game/World/ClientlessPlayerMgr.cpp +++ b/src/server/game/World/ClientlessPlayerMgr.cpp @@ -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; } diff --git a/src/server/game/World/ClientlessTransportMgr.cpp b/src/server/game/World/ClientlessTransportMgr.cpp new file mode 100644 index 0000000000..49805399cc --- /dev/null +++ b/src/server/game/World/ClientlessTransportMgr.cpp @@ -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 +#include +#include +#include + +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(1800000, std::max(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::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 OR travel "); 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 (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 [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 "); 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 "); 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 ."); 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 OR transport near ."); 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 "); 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 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 ."); + 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 , near [radius], inspect [botGuid], stops, stand add|list, record start|mark|info|stop|cancel, link [both], forget , reload, status|cancel , travel ."); + return true; +} diff --git a/src/server/game/World/ClientlessTransportMgr.h b/src/server/game/World/ClientlessTransportMgr.h new file mode 100644 index 0000000000..a5f83c2ab0 --- /dev/null +++ b/src/server/game/World/ClientlessTransportMgr.h @@ -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 +#include +#include + +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 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 _stops; + std::unordered_map _recordings; + std::unordered_map _rides; +}; + +#define sClientlessTransportMgr ClientlessTransportMgr::instance() +#endif diff --git a/src/server/game/World/ClientlessTravelGraphMgr.cpp b/src/server/game/World/ClientlessTravelGraphMgr.cpp index e9c7b59fab..978c322de0 100644 --- a/src/server/game/World/ClientlessTravelGraphMgr.cpp +++ b/src/server/game/World/ClientlessTravelGraphMgr.cpp @@ -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 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::infinity(); + std::vector 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::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 rawExitCandidates; std::unordered_map> exitsByFrom; - uint32 exitTests = 0, exitAccepted = 0; if (locationGoal) { - float const radius = std::min(300.0f, locationGoal->AnchorRadius); + float const radius = locationAccessRadius; std::unordered_set 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::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::infinity(); - std::vector 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::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::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::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::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"; diff --git a/src/server/game/World/World.cpp b/src/server/game/World/World.cpp index 896b16880c..b6827be989 100644 --- a/src/server/game/World/World.cpp +++ b/src/server/game/World/World.cpp @@ -3414,6 +3414,7 @@ void World::UpdateSessions(uint32 diff) continue; } + session->CompleteClientlessTeleport(); WorldSessionFilter updater(session); if (!session->Update(diff, updater)) { diff --git a/src/server/scripts/Custom/clientless/adventure/overrides/arthoran.lua b/src/server/scripts/Custom/clientless/adventure/overrides/arthoran.lua index e5c61a799f..e171d25eb6 100644 --- a/src/server/scripts/Custom/clientless/adventure/overrides/arthoran.lua +++ b/src/server/scripts/Custom/clientless/adventure/overrides/arthoran.lua @@ -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 ---}) \ No newline at end of file +-- 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}) diff --git a/src/server/scripts/Custom/viano_bot_commands.cpp b/src/server/scripts/Custom/viano_bot_commands.cpp index faac6b0171..895f08bc8a 100644 --- a/src/server/scripts/Custom/viano_bot_commands.cpp +++ b/src/server/scripts/Custom/viano_bot_commands.cpp @@ -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 "); + 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::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 ]" : ""); 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 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 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; diff --git a/src/server/worldserver/worldserver.conf.dist b/src/server/worldserver/worldserver.conf.dist index 8de8a30c67..408484bc67 100644 --- a/src/server/worldserver/worldserver.conf.dist +++ b/src/server/worldserver/worldserver.conf.dist @@ -68,6 +68,15 @@ RealmID = 1 # +# Clientless.ScriptRoot +# Description: Lua root read directly on login and .clientless luareload . +# 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. diff --git a/tests/clientless/test_lua_reload.py b/tests/clientless/test_lua_reload.py new file mode 100644 index 0000000000..50e84e495a --- /dev/null +++ b/tests/clientless/test_lua_reload.py @@ -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 +#include +#include +#include +#include +#include +#include +using uint32 = uint32_t; +#define TC_LOG_INFO(...) ((void)0) +struct ClientlessIntent { + std::string Name; + std::unordered_map 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 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 _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 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).") diff --git a/tests/clientless/test_portal_transfer.py b/tests/clientless/test_portal_transfer.py new file mode 100644 index 0000000000..14a407d020 --- /dev/null +++ b/tests/clientless/test_portal_transfer.py @@ -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 +#include +#include +#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.") diff --git a/tests/clientless/test_taxi_import_disabled.py b/tests/clientless/test_taxi_import_disabled.py new file mode 100644 index 0000000000..2899cf9415 --- /dev/null +++ b/tests/clientless/test_taxi_import_disabled.py @@ -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.') diff --git a/tests/clientless/test_transport.py b/tests/clientless/test_transport.py new file mode 100644 index 0000000000..f8f1572fb7 --- /dev/null +++ b/tests/clientless/test_transport.py @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +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;templateQueryResult 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::mapPath;std::mapRotations;}; +struct Animated { + GameObject _owner;TransportAnimation* _animationInfo=nullptr;uint32 _pathProgress=0;std::vector_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::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) diff --git a/tests/clientless/test_transport_grid_loading.py b/tests/clientless/test_transport_grid_loading.py new file mode 100644 index 0000000000..eb4c248f0c --- /dev/null +++ b/tests/clientless/test_transport_grid_loading.py @@ -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 +#include +#include +#include +#include +#include +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> _transportGameObjectSpawns; + std::unordered_map 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 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> 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).")