Compare commits
2
Commits
01a9ba4b0b
...
4474714c00
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4474714c00 | ||
|
|
1164a72ea3 |
No files matched your search
@@ -0,0 +1,59 @@
|
||||
# Ödland: Anleger ↔ Klippe, Trigger 6321/6322
|
||||
|
||||
Stand 26.10.2026. Laut Nutzer soll der Anleger ein erreichbares Bot-Reiseziel
|
||||
sein; diese Verbindung ist dafür wichtig. Beide gleichkartigen Übergänge sind
|
||||
im lokalen Testgraph aktiviert. Native Trigger und Ziele bleiben unverändert.
|
||||
|
||||
## Native Daten und Import
|
||||
|
||||
Beide Trigger liegen auf Karte 0, haben Radius 1.78776 und keine DBC-
|
||||
Phasenflags. Die lokale DB enthält keine eigenen AreaTrigger-Skripte und
|
||||
keine direkten Questzuordnungen über `areatrigger_involvedrelation` für diese
|
||||
IDs; in den untersuchten Core-/Skriptdateien gibt es keine Sonderbehandlung
|
||||
der IDs. Das ist keine pauschale Aussage über jede denkbare Zugangsbedingung.
|
||||
|
||||
Reproduktion über die Testserver-Konsole:
|
||||
|
||||
```text
|
||||
clientless portal import 6321
|
||||
clientless portal import 6322
|
||||
```
|
||||
|
||||
Der Import legt vier Nodes und zwei gerichtete Kanten an und lädt den Graph
|
||||
neu. Keine Höhenkalibrierung oder zusätzliche Migration erforderlich.
|
||||
|
||||
| Node | X | Y | Z | Funktion |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| portal_at_6321_source | -6518.680 | -4604.420 | 29.2335 | Anleger, hinauf |
|
||||
| portal_at_6321_target | -6547.880 | -4443.240 | 332.079 | Ankunft an der Klippe |
|
||||
| portal_at_6322_source | -6551.280 | -4446.460 | 332.829 | Klippe, hinunter |
|
||||
| portal_at_6322_target | -6519.820 | -4608.520 | 28.4842 | Ankunft am Anleger |
|
||||
|
||||
Portal-Kanten 1750 (6321_source → 6321_target) und 1751
|
||||
(6322_source → 6322_target), Kosten jeweils 1, unidirektional.
|
||||
|
||||
## Live-Test
|
||||
|
||||
Arthoran wurde nach Sicherung seiner Ausgangsdaten ausgeloggt am unteren
|
||||
Source-Node positioniert. Danach reguläre Reiseaufträge ohne weitere
|
||||
Positionsmanipulation während der Fahrt:
|
||||
|
||||
- 12:05:54: Trigger 6321, Anleger → Klippe, `EDGE_COMPLETE`,
|
||||
`arrivalError=0.00`, `recovery=0`.
|
||||
- Oben vollständig validierte MMAP-Brücke vom Ankunftspunkt zum Rücktrigger:
|
||||
4.75 Yards Pfadlänge, tatsächliche Bewegung, ohne Recovery.
|
||||
- 12:05:55: Trigger 6322, Klippe → Anleger, `EDGE_COMPLETE`,
|
||||
`arrivalError=0.00`, `recovery=0`.
|
||||
|
||||
Der zweite erfolgreiche Umlauf (12:06:18–12:06:19) ab der natürlichen unteren
|
||||
Ankunft bestätigte außerdem den Anlauf von `portal_at_6322_target` zu
|
||||
`portal_at_6321_source`. Alle vier Portalabschlüsse hatten Ankunftsfehler 0
|
||||
und Recovery 0. Arthoran wurde anschließend ausgeloggt; Ausgangsposition, Zone
|
||||
und Lua-Skript wurden wiederhergestellt.
|
||||
Die Portalreisen verwenden den normalen AreaTrigger-Handler mit
|
||||
Clientless-Near-ACK und Zielprüfung.
|
||||
|
||||
Es wurden keine Groundanschlüsse an das übrige Ödlandnetz aufgenommen oder
|
||||
getestet. Keine Coreänderung, kein Build und kein Serverneustart erforderlich.
|
||||
Finale Ingame-Abnahme durch den Nutzer steht aus.
|
||||
Sicherungen und Logs: `/tmp/vcore-badlands-test.0lkimsmi`.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Interaktive Portale: Live-Test Dunkles Portal → Sturmwind
|
||||
|
||||
Implementierung und Bedienung: [docs/interactive-portals.md](../../docs/interactive-portals.md).
|
||||
Stand 26.10.2026. Keine künstlichen Teleports, keine Änderungen an nativen
|
||||
GameObject- oder Zauberdefinitionen.
|
||||
|
||||
## Erfolgreicher Teststandort
|
||||
|
||||
| Merkmal | Wert |
|
||||
| --- | --- |
|
||||
| GameObject-Spawn | 170 |
|
||||
| Entry / Name | 195139 / Portal to Stormwind |
|
||||
| Standort | Scherbenweltseite beim Dunklen Portal |
|
||||
| Quelle | Karte 530, (-337.492, 962.619, 54.2885) |
|
||||
| Nativer Zauber | 17334, Teleporteffekt 0 mit TARGET_DEST_DB |
|
||||
| Ziel | Karte 0, (-8998.140, 861.254, 29.6206), Orientierung 2.25 |
|
||||
| Nodes | portal_go_170_source → portal_go_170_target |
|
||||
| Kante | 1755, portal_use, Kosten 1, unidirektional |
|
||||
|
||||
Gezielt importiert mit `clientless portal object import 170`. Arthoran
|
||||
(GUID 26) begann ausgeloggt acht Yards vor der nativen Objektposition; nach
|
||||
Login erhielt er einen normalen Reiseauftrag zum Zielnode. Während der Reise
|
||||
keine Positionsmanipulation:
|
||||
|
||||
- 13:28:36: MMAP-Anlauf, 8 Yards, vollständiger Pfad.
|
||||
- 13:28:38: Source erreicht; normaler CMSG_GAMEOBJ_USE-Handler für Spawn 170.
|
||||
- 13:28:39: `EDGE_COMPLETE`, `edgeType='portal_use'`, Karte 0,
|
||||
`arrivalError=0.00`, `recovery=0`, anschließend regulärer Reiseabschluss.
|
||||
|
||||
Das bestätigt serverseitig Anlauf, Objektbenutzung, nativen Portalzauber,
|
||||
Far-Teleport mit Clientless-ACK und Ankunft. Visuelle Ingame-Abnahme durch den
|
||||
Nutzer steht aus. Keine Rückrichtung erfunden, keine Groundanschlüsse
|
||||
aufgenommen und keine NPC-/Gossip-Portale als unterstützt behauptet.
|
||||
|
||||
## Untersuchte Shattrath-Verbindung
|
||||
|
||||
Erster Kandidat: Spawn 83, Entry 183325, `Shattrath Portal to Stormwind`,
|
||||
gleicher Zauber 17334, Quelle (-1894.190, 5387.940, -12.4283) auf Karte 530.
|
||||
Das native Zauberziel ist identisch zum erfolgreichen zweiten Standort.
|
||||
|
||||
Der Klick erreichte die normale native Zauberausführung, aber die Effekte
|
||||
wurden in `Spell::AddUnitTarget` nach `CheckEffectTarget` verworfen. Die
|
||||
zusätzliche Diagnose zeigte `resolved=1` für Arthoran und `no effects after
|
||||
target/LOS checks` für beide Effekte. Bei diesen Effekten ist dort die native
|
||||
Sichtlinienprüfung maßgeblich. Das trat an der Objektmitte und drei Yards davor
|
||||
auf. Der genaue geometrische Grund wurde nicht geändert oder abschließend
|
||||
vermessen. Die temporäre Zauberdiagnose wurde nach der Untersuchung entfernt.
|
||||
|
||||
Der Versuch scheiterte sowohl bei Level 20 als auch bei Level 60; niedriges
|
||||
Level erklärt diesen Befund daher nicht. Die Erhöhung auf Level 60 erfolgte
|
||||
mit dem konsolenfähigen nativen Befehl `character level Arthoran 60` im Rahmen
|
||||
der Nutzerfreigabe. `.levelup` ist in dieser Core-Kommandotabelle ingame-only.
|
||||
|
||||
Die erfolglose experimentelle Kante 1752 sowie ihre beiden Graphnodes und
|
||||
Weltpunkte sind jetzt deaktiviert. Ihre Definition bleibt zur Diagnose
|
||||
erhalten. Der veränderte Graph-Anlaufpunkt liegt bei X=-1897.190; native
|
||||
GO-Position und Zauberdaten sind unverändert. Beim Wiederholungsimport bleiben
|
||||
Anlaufposition und Deaktivierung erhalten. Der native Standort muss vor einer
|
||||
späteren Freigabe gesondert geprüft werden.
|
||||
|
||||
## Import- und Fehlertests
|
||||
|
||||
- Wiederholter Import: keine zusätzlichen Nodes/Kanten; bestehende Koordinaten
|
||||
und enabled-Flags werden nicht überschrieben.
|
||||
- Fehlgeschlagene native Ausführung: nach 10 Sekunden korrekt `Failed`, kein
|
||||
behaupteter Reiseerfolg und kein Ersatzteleport.
|
||||
- Shattrath → Quel’Danas, Spawn 47196 / Zauber 44876: Import sauber vor dem
|
||||
Anlegen von Nodes abgewiesen; Zauberkette statt direktem DB-Teleport.
|
||||
- Automatisierte Tests führen Produktionsmethoden mit Core-Doubles aus:
|
||||
direkte Zielauflösung, Objektprüfung, Klick, Near/Far/globaler ACK,
|
||||
Ablehnung, Timeout, falsches Ziel und ACK-Sequenzzähler.
|
||||
- Bestehende Portal-, Transport-, Grid-, Lua-Reload- und Taxi-Importer-Tests
|
||||
bestanden ebenfalls.
|
||||
|
||||
Build und Installation bestanden. Die tatsächlichen Portalversuche wurden
|
||||
mit installiertem Code ausgeführt. Anschließend wurde der endgültige Stand
|
||||
ohne zusätzliche temporäre Zauberdiagnose gebaut und installiert. Finale
|
||||
Build-ID: `613c4236bce2212d49788e771b66f490b617d8ca`.
|
||||
|
||||
Der letzte Start wurde anhand seiner vollständigen frischen Konsole geprüft:
|
||||
Ready-Meldung vorhanden, keine kritischen Startfehler im geprüften Output,
|
||||
Prozess 53332 läuft und Port 8085 lauscht. Keine weiteren Botaufträge beim
|
||||
finalen Start. Der ursprüngliche Loglevel gilt wieder.
|
||||
|
||||
Arthoran wurde nach dem Test ausgeloggt. Ausgangsposition, Zone und Lua sind
|
||||
wiederhergestellt; das freigegebene Level 60 bleibt erhalten. Vorherdaten,
|
||||
Diagnose und Testlogs liegen in `/tmp/vcore-interactive-portal.8vfrq7g2`.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Lua im laufenden Testserver ändern
|
||||
|
||||
`.clientless luareload 26` liest Arthorans Profil und Adventure-Skript erneut von
|
||||
der Festplatte und führt das Verhalten einmal aus. In der Serverkonsole entfällt
|
||||
der führende Punkt. Der Befehl benötigt GM-Rechte und funktioniert für jeden
|
||||
eingeloggten, verwalteten clientlosen Spieler anhand seiner GUID.
|
||||
|
||||
Die Antwort enthält den tatsächlich verwendeten absoluten Skriptpfad. Es gelten
|
||||
weiterhin die vorhandenen Fallbacks: `adventure/profiles/<name>.lua` oder
|
||||
`adventure/profiles/generic.lua` sowie `adventure/overrides/<name>.lua` oder
|
||||
`adventure/generic.lua`. Die Engine hält keine kompilierten Skripte im Cache.
|
||||
|
||||
Für Entwicklung kann `worldserver.conf` direkt auf die Quelldateien zeigen:
|
||||
|
||||
```ini
|
||||
Clientless.ScriptRoot = "/home/wow_admin/wow_cata_server/source/TrinityCore/src/server/scripts/Custom/clientless"
|
||||
```
|
||||
|
||||
Der Standard bleibt `scripts/clientless`, relativ zum Arbeitsverzeichnis des
|
||||
Worldservers. Eine nichtleere Umgebungsvariable `VCORE_CLIENTLESS_SCRIPT_DIR`
|
||||
hat Vorrang. Eine Änderung des Stammverzeichnisses erfordert einen Neustart;
|
||||
anschließende Lua-Dateiänderungen benötigen weder Build noch Installation oder
|
||||
Neustart. Die vorhandene Installation der Lua-Dateien bleibt für eigenständige
|
||||
Serverinstallationen erhalten.
|
||||
|
||||
Der Reload setzt einen untätigen Bot mit leerer Aktionswarteschlange voraus.
|
||||
Laufende Reisen, Flüge, Teleports und Passagiere auf Transportmitteln werden
|
||||
abgewiesen, ohne diese abzubrechen. Der Befehl hängt daher keine zusätzliche
|
||||
Reise an eine bereits laufende Reise an. Ein erfolgreicher Reload kann sofort
|
||||
die im geänderten Skript eingereihten Aktionen starten.
|
||||
|
||||
Profil und Verhalten werden zusammen geladen. Bei Lade- oder Laufzeitfehlern
|
||||
bleibt das bisherige Profil erhalten. Während des Reloads angeforderte Aktionen
|
||||
und Intent-Änderungen werden erst nach Erfolg übernommen. Lua-Logmeldungen
|
||||
können auch aus einem später fehlgeschlagenen Reload stammen. Fehlerdetails
|
||||
stehen im Serverlog; das Ergebnis wird zusätzlich als `LUA_RELOAD` im
|
||||
Navigationslog protokolliert. Lokale Variablen des alten Skriptaufrufs werden
|
||||
nicht fortgeführt; das entspricht dem bisherigen einmaligen Login-Aufruf.
|
||||
|
||||
Regressionstest: `python3 tests/clientless/test_lua_reload.py` (C++-Compiler und
|
||||
Lua-5.4-Entwicklungspaket erforderlich).
|
||||
@@ -0,0 +1,24 @@
|
||||
# Portal-Auswahl für AdventureBots
|
||||
|
||||
Nutzerentscheidungen vom 26.10.2026, zusätzlich zur historischen
|
||||
Bestandsaufnahme in `same-map-portals.md/json`:
|
||||
|
||||
| Übergänge | Entscheidung |
|
||||
| --- | --- |
|
||||
| 527/542, Rut’theran ↔ Darnassus | Aktiv, live getestet. |
|
||||
| 702/704, Magierturm Sturmwind | Aktiv, live getestet; Groundanschluss wird vom Nutzer später aufgenommen. |
|
||||
| 6321/6322, Ödland Anleger ↔ Klippe | Als Bot-Reiseziel gewünscht; gezielt importiert und live getestet. |
|
||||
| 4233/4267, Duskwither im Immersangwald | Außerhalb des Graphen lassen. Laut Nutzer questbezogen und kein gewünschtes Reiseziel. |
|
||||
| 5273, Dalaran-Brunnen | Vorerst außerhalb lassen. Laut Nutzer voraussichtlich Sprung in den Brunnen; alternative Zugänge zur Kanalisation vorhanden. |
|
||||
| Arena-interne Umsetzungen | Für den aktuellen Reisegraph irrelevant; nicht importieren. |
|
||||
| Eindeutige Quest-/Phasenübergänge | Derzeit nicht allgemein freigeben. |
|
||||
| Dungeon-/Raid-interne Übergänge | Zurückstellen; für spätere Gruppenbegleitung gegebenenfalls erneut bewerten. |
|
||||
|
||||
Dungeon-Eingänge sind ausdrücklich relevante Reiseziele und wurden deshalb
|
||||
bereits importiert. Das bedeutet keine Freigabe selbstständiger Bewegung
|
||||
innerhalb einer Instanz. Für allein reisende AdventureBots genügt gemäß
|
||||
Nutzerwunsch perspektivisch ein zeitlich simulierter Aufenthalt mit späterer
|
||||
Rückkehr am Ausgang. Diese Aktion ist durch diese Portalarbeit **nicht
|
||||
implementiert**. Die spätere Begleitung von Spielergruppen innerhalb von
|
||||
Dungeons ist gesondert zu behandeln; interne Teleporter werden deshalb nicht
|
||||
pauschal als dauerhaft unnötig eingeordnet. DungeonBots bleiben unabhängig.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Rut’theran / Darnassus: Graphkorrektur vom 26.10.2026
|
||||
|
||||
`taxi_585` wurde auf GM-Anweisung ausschließlich im CPC-TravelGraph und dessen
|
||||
Weltpunkttabelle deaktiviert. Der Punkt bei (8334.76, 1029.83, 22.79) ist kein
|
||||
regulärer Spieler-Flugpunkt. Es waren keine Kanten mit diesem Node verbunden.
|
||||
Native Taxi-/NPC-Definitionen bleiben erhalten. Die explizite Testmigration
|
||||
steht in `sql/custom/world/2026_10_26_00_clientless_disable_taxi_585.sql`.
|
||||
|
||||
Der Taxi-Importer behält bestehende `enabled=0`-Flags und deren Kommentare bei,
|
||||
anstatt sie bei jedem Neuimport zu aktivieren. Neue Taxi-Datensätze werden wie
|
||||
bisher aktiviert angelegt. Ein SQL-Regressionstest führt die tatsächlichen
|
||||
UPDATE-Anweisungen mit aktiven und deaktivierten Datensätzen aus:
|
||||
`python3 tests/clientless/test_taxi_import_disabled.py`.
|
||||
|
||||
## Fehlende Portale
|
||||
|
||||
Der frühere Portalimport übersprang gleichkartige Übergänge, und der Executor
|
||||
lehnte sie ausdrücklich ab. Rut’theran und Darnassus liegen beide auf Karte 1.
|
||||
Die allgemeine Clientless-Near-Teleport-Bestätigung ist seit der
|
||||
Transportintegration vorhanden und wird jetzt auch vom Portal-Executor benutzt.
|
||||
Portalreisen durchlaufen weiterhin den normalen AreaTrigger-Handler mit dessen
|
||||
Zugangsprüfungen. Vor Fortsetzung muss der Teleport abgeschlossen sein und die
|
||||
Position höchstens fünf Yards vom tatsächlichen Ziel entfernt liegen. Dadurch
|
||||
kann ein verweigerter gleichkartiger Teleport nicht als Erfolg gelten.
|
||||
|
||||
`.clientless portal import <triggerId>` erlaubt nun einen gezielten Import,
|
||||
einschließlich gleichkartiger Portale. Der bisherige Bulk-Import ohne Argument
|
||||
behält seinen Umfang (Kartenwechsel) bei. `portal import` und `portal info` sind
|
||||
mit denselben GM-Rechten auch über die Konsole nutzbar.
|
||||
|
||||
Aus den installierten DBC-/DB-Daten abgeleitete Nodes, alle auf Karte 1:
|
||||
|
||||
| Ort | Node | X | Y | Z |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Rut’theran, nach Darnassus | portal_at_542_source | 8393.800 | 1003.900 | 30.191 |
|
||||
| Darnassus, Ankunft | portal_at_542_target | 9946.250 | 2612.970 | 1316.490 |
|
||||
| Darnassus, nach Rut’theran | portal_at_527_source | 9947.480 | 2630.040 | 1318.600 |
|
||||
| Rut’theran, Ankunft | portal_at_527_target | 8380.560 | 998.536 | 29.129 |
|
||||
|
||||
Gerichtete Portalkanten: 542_source → 542_target (ID 1747) und
|
||||
527_source → 527_target (ID 1746), Kosten jeweils 1. Groundanschlüsse werden
|
||||
separat aufgezeichnet oder vom Planner vollständig per MMAP validiert.
|
||||
|
||||
## Verifikation
|
||||
|
||||
Build, Installation und Regressionstests bestanden. Echter Live-Test mit
|
||||
Arthoran: Trigger 542 um 10:26:22, Trigger 527 um 10:27:17, jeweils
|
||||
`EDGE_COMPLETE`, `arrivalError=0.00`, Recovery 0. Die Rückfahrt nutzte zunächst
|
||||
eine validierte MMAP-Brücke von `portal_at_542_target` zum 17,24 Yards entfernten
|
||||
`portal_at_527_source`; danach wurde der reguläre AreaTrigger ausgeführt.
|
||||
|
||||
`tests/clientless/test_portal_transfer.py` prüft den tatsächlichen
|
||||
Handler-/ACK-/Zielprüfungsblock mit Core-Doubles: Near und Far, abgewiesener
|
||||
Same-Map-Teleport, fehlender ACK-Abschluss und falscher Sessiontyp.
|
||||
Die existierenden Transport- und Lua-Reload-Regressionstests bestanden ebenfalls.
|
||||
|
||||
Ausgangsdaten, neu erzeugte Portalnodes und vorheriges Arthoran-Lua liegen unter
|
||||
`/tmp/vcore-ruttheran-fix.yxm90j91`. Ein laufender GM-Aufnahmebuffer wurde vor
|
||||
dem Neustart ausdrücklich als gespeichert bestätigt und im Log nachgewiesen.
|
||||
|
||||
Die abschließend installierte Build-ID ist
|
||||
`f2873b12546ada19e68aa3b6b0d8e7823fe816e5` (einschließlich korrigierter
|
||||
`portal info`-Anzeige). Ready-Meldung, laufender Prozess 42675 und Port 8085
|
||||
wurden nach dem finalen Start geprüft. Arthoran wurde nach beiden Tests sauber
|
||||
ausgeloggt; das vorherige Lua-Skript ist wiederhergestellt.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,101 @@
|
||||
# Vom Bulk-Import übersprungene gleichkartige Portalübergänge
|
||||
|
||||
Historische Bestandsaufnahme vom 26.10.2026 vor dem Magierturm-Import, ausschließlich lesender Abgleich der lokalen Testdatenbank mit den installierten AreaTrigger-/Map-DBC-Dateien.
|
||||
|
||||
Nachtrag: 702/704 und 6321/6322 sind inzwischen importiert und als Portalübergänge live getestet. Damit bleiben 54 der hier ursprünglich fehlenden 58 Übergänge unimportiert; das bedeutet keine geplante Freigabe dieser restlichen Übergänge. Die aktuelle Auswahl steht in [portal-selection.md](portal-selection.md), die Ödland-Tests in [badlands-dock-portals.md](badlands-dock-portals.md). Der noch fehlende Stadtanschluss ist in [stormwind-mage-tower.md](stormwind-mage-tower.md) dokumentiert. Die folgende Tabelle und die JSON-Datei zeigen weiterhin den Vorherstand.
|
||||
|
||||
Teleportdefinitionen: 316; davon gleichkartig: 60, kartenwechselnd: 256, ohne passenden DBC-Trigger: 0.
|
||||
|
||||
Aktuell vollständig aktiv im Graph: 2 gleichkartige Übergänge. Der Bulk-Importer überspringt sie weiterhin; gezielter Import ist möglich. Keine Daten wurden verändert.
|
||||
|
||||
## Einordnung der 58 zusätzlich fehlenden Übergänge
|
||||
|
||||
Alle 58 fehlen sowohl als Source-Node und Target-Node als auch als Portal-Kante: insgesamt 116 fehlende Nodes und 58 fehlende gerichtete Kanten. Die bereits ergänzten Trigger 527 und 542 sind vollständig aktiv.
|
||||
|
||||
| Gruppe nach DB-Bezeichnung/Kartentyp | Trigger | Anzahl |
|
||||
| --- | --- | --- |
|
||||
| Sturmwind, Magierturm | 702, 704 | 2 |
|
||||
| Immersangwald, Duskwither-Teleporter | 4233, 4267 | 2 |
|
||||
| Dalaran, Brunnen | 5273 | 1 |
|
||||
| Acherus/Ebon Hold, interne Ebenenübergänge | 5127–5138 | 12 |
|
||||
| Hyjal/Feuerlande, Outdoor-Übergänge | 5879, 5880, 5893, 5895, 5931, 5937 | 6 |
|
||||
| Ödland, Klippen-/Anlegerübergänge | 6321, 6322 | 2 |
|
||||
| Tausend Nadeln, Test of Faith | 943 | 1 |
|
||||
| Scherbenwelt, Invasion Point | 4561, 4562 | 2 |
|
||||
| Hyjal-Schlachtzug, Basisübergänge | 4311–4313 | 3 |
|
||||
| Hallen der Reflexion, Türübergang | 5740 | 1 |
|
||||
| Verlorene Inseln, Volcanoth/Phase 8+ | 5926 | 1 |
|
||||
| Arena-Start-/Umsetzungstrigger auf fünf Arenakarten | siehe vollständige Liste | 25 |
|
||||
|
||||
Diese Gruppen sind eine Einordnung anhand der gespeicherten Namen und nativen Kartentypen. Sie ersetzen keine Prüfung der Quest-, Fraktions-, Phasen- und Instanzbedingungen. Insbesondere Arena-Starttrigger sind keine frei nutzbaren überregionalen Reisekanten.
|
||||
|
||||
Als nächsten gezielten Kandidaten zuerst **702/704 in Sturmwind** prüfen, danach nach Bedarf Duskwither und den Dalaran-Brunnen. Den übrigen Bestand erst nach Prüfung seiner tatsächlichen Spielmechanik und Zugänglichkeit ergänzen. Es wurde nichts importiert, aktiviert oder am laufenden Server geändert.
|
||||
|
||||
## Vollständiger Bestand der 60 gleichkartigen Übergänge
|
||||
|
||||
| Trigger | Name laut DB | Karte | Graphstatus | Source X/Y/Z | Target X/Y/Z |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 527 | Darnassus Exit Target | 1 – Kalimdor | aktiv | 9947.48 / 2630.04 / 1318.60 | 8380.56 / 998.54 / 29.13 |
|
||||
| 542 | Darnassus Entrance Target | 1 – Kalimdor | aktiv | 8393.80 / 1003.90 / 30.19 | 9946.25 / 2612.97 / 1316.49 |
|
||||
| 702 | Stormwind - Mage Exit Target | 0 – Eastern Kingdoms | fehlt/unvollständig | -9020.67 / 890.10 / 34.00 | -9014.94 / 873.33 / 148.62 |
|
||||
| 704 | Stormwind - Mage Entrance Target | 0 – Eastern Kingdoms | fehlt/unvollständig | -9017.37 / 877.30 / 152.00 | -9016.97 / 885.44 / 29.62 |
|
||||
| 943 | Thousand Needles - Test of Faith (Target) | 1 – Kalimdor | fehlt/unvollständig | -4950.74 / -1595.14 / -3.02 | -5191.27 / -2802.59 / -7.21 |
|
||||
| 4233 | Eversong Woods - Duskwither UP Target | 530 – Outland | fehlt/unvollständig | 9336.22 / -7883.46 / 74.91 | 9331.49 / -7812.27 / 136.57 |
|
||||
| 4267 | Eversong Woods - Duskwither DOWN Target | 530 – Outland | fehlt/unvollständig | 9285.97 / -7916.01 / 176.13 | 9334.03 / -7880.02 / 74.91 |
|
||||
| 4311 | CoT - Hyjal - Human Base - Teleport Target | 534 – The Battle for Mount Hyjal | fehlt/unvollständig | 4162.02 / -4227.07 / 869.18 | 5066.79 / -1791.90 / 1321.65 |
|
||||
| 4312 | CoT - Hyjal - Orc Base - Teleport Target | 534 – The Battle for Mount Hyjal | fehlt/unvollständig | 4284.15 / -4138.85 / 866.81 | 5499.96 / -2756.80 / 1488.96 |
|
||||
| 4313 | CoT - Hyjal - Night Elf Base - Teleport Target | 534 – The Battle for Mount Hyjal | fehlt/unvollständig | 4203.64 / -4103.24 / 878.36 | 5163.02 / -3428.31 / 1627.61 |
|
||||
| 4561 | Invasion Point, Cataclysm (Return Point) | 530 – Outland | fehlt/unvollständig | -2829.78 / 1953.76 / 202.46 | -3278.63 / 2831.31 / 123.01 |
|
||||
| 4562 | Invasion Point, Cataclysm (Return Point) | 530 – Outland | fehlt/unvollständig | -2745.24 / 2730.42 / 127.78 | -3278.63 / 2831.31 / 123.01 |
|
||||
| 4917 | Arena - Nagrand - Teleport Target - Game On | 559 – Nagrand Arena | fehlt/unvollständig | 4054.15 / 2923.70 / -40.00 | 4054.57 / 2923.23 / 13.82 |
|
||||
| 4919 | Arena - Bladesedge - Teleport Target - Game On | 562 – Blade's Edge Arena | fehlt/unvollständig | 6188.01 / 320.00 / 20.00 | 6238.89 / 264.15 / 0.89 |
|
||||
| 4921 | Arena - Bladesedge - Teleport Target - Game On | 562 – Blade's Edge Arena | fehlt/unvollständig | 6288.22 / 210.00 / 20.00 | 6238.89 / 264.15 / 0.89 |
|
||||
| 4922 | Arena - Bladesedge - Teleport Target - Game On | 562 – Blade's Edge Arena | fehlt/unvollständig | 6214.87 / 200.26 / 20.00 | 6238.89 / 264.15 / 0.89 |
|
||||
| 4923 | Arena - Bladesedge - Teleport Target - Game On | 562 – Blade's Edge Arena | fehlt/unvollständig | 6187.77 / 196.88 / 20.00 | 6238.89 / 264.15 / 0.89 |
|
||||
| 4924 | Arena - Bladesedge - Teleport Target - Game On | 562 – Blade's Edge Arena | fehlt/unvollständig | 6264.76 / 329.75 / 3.41 | 6238.89 / 264.15 / 0.89 |
|
||||
| 4925 | Arena - Bladesedge - Teleport Target - Game On | 562 – Blade's Edge Arena | fehlt/unvollständig | 6290.77 / 333.41 / 20.00 | 6238.89 / 264.15 / 0.89 |
|
||||
| 4927 | Arena - Undercity - Teleport Target - Game On | 572 – Ruins of Lordaeron | fehlt/unvollständig | 1200.33 / 1682.09 / 33.08 | 1286.57 / 1667.40 / 39.60 |
|
||||
| 4928 | Arena - Undercity - Teleport Target - Game On | 572 – Ruins of Lordaeron | fehlt/unvollständig | 1223.61 / 1630.81 / 32.61 | 1286.57 / 1667.40 / 39.60 |
|
||||
| 4929 | Arena - Undercity - Teleport Target - Game On | 572 – Ruins of Lordaeron | fehlt/unvollständig | 1359.18 / 1692.25 / 37.07 | 1286.57 / 1667.40 / 39.60 |
|
||||
| 4930 | Arena - Undercity - Teleport Target - Game On | 572 – Ruins of Lordaeron | fehlt/unvollständig | 1371.51 / 1649.08 / 34.51 | 1286.57 / 1667.40 / 39.60 |
|
||||
| 4931 | Arena - Undercity - Teleport Target - Game On | 572 – Ruins of Lordaeron | fehlt/unvollständig | 1299.76 / 1554.74 / 40.00 | 1286.57 / 1667.40 / 39.60 |
|
||||
| 4932 | Arena - Undercity - Teleport Target - Game On | 572 – Ruins of Lordaeron | fehlt/unvollständig | 1274.51 / 1774.68 / 27.04 | 1286.57 / 1667.40 / 39.60 |
|
||||
| 4933 | Arena - Undercity - Teleport Target - Game On | 572 – Ruins of Lordaeron | fehlt/unvollständig | 1246.06 / 1743.62 / 40.00 | 1286.57 / 1667.40 / 39.60 |
|
||||
| 4934 | Arena - Undercity - Teleport Target - Game On | 572 – Ruins of Lordaeron | fehlt/unvollständig | 1305.44 / 1754.14 / 40.00 | 1286.57 / 1667.40 / 39.60 |
|
||||
| 4935 | Arena - Undercity - Teleport Target - Game On | 572 – Ruins of Lordaeron | fehlt/unvollständig | 1321.62 / 1586.98 / 40.00 | 1286.57 / 1667.40 / 39.60 |
|
||||
| 4936 | Arena - Undercity - Teleport Target - Game On | 572 – Ruins of Lordaeron | fehlt/unvollständig | 1269.12 / 1579.99 / 40.00 | 1286.57 / 1667.40 / 39.60 |
|
||||
| 4941 | Arena - Undercity - Teleport Target - Game On | 572 – Ruins of Lordaeron | fehlt/unvollständig | 1285.36 / 1667.68 / 10.00 | 1286.57 / 1667.40 / 39.60 |
|
||||
| 4944 | Arena - Bladesedge - Teleport Target - Game On | 562 – Blade's Edge Arena | fehlt/unvollständig | 6237.84 / 261.36 / -20.00 | 6238.89 / 264.15 / 0.89 |
|
||||
| 5127 | Ebon Hold (E.K.) Top Return Target | 0 – Eastern Kingdoms | fehlt/unvollständig | 2479.34 / -5495.73 / 421.84 | 2462.76 / -5598.19 / 550.22 |
|
||||
| 5128 | Ebon Hold (E.K.) Top West Target | 0 – Eastern Kingdoms | fehlt/unvollständig | 2469.29 / -5545.84 / 546.10 | 2472.67 / -5530.10 / 420.64 |
|
||||
| 5129 | Ebon Hold (E.K.) Top Return Target | 0 – Eastern Kingdoms | fehlt/unvollständig | 2362.55 / -5574.54 / 421.84 | 2462.76 / -5598.19 / 550.22 |
|
||||
| 5130 | Ebon Hold (E.K.) Top South Target | 0 – Eastern Kingdoms | fehlt/unvollständig | 2412.68 / -5584.65 / 546.10 | 2397.37 / -5581.06 / 420.64 |
|
||||
| 5131 | Ebon Hold (E.K.) Top Return Target | 0 – Eastern Kingdoms | fehlt/unvollständig | 2558.37 / -5612.56 / 421.83 | 2462.76 / -5598.19 / 550.22 |
|
||||
| 5132 | Ebon Hold (E.K.) Top North Target | 0 – Eastern Kingdoms | fehlt/unvollständig | 2508.14 / -5602.50 / 546.10 | 2522.45 / -5605.70 / 420.64 |
|
||||
| 5133 | Ebon Hold (E.K.) Top Return Target | 0 – Eastern Kingdoms | fehlt/unvollständig | 2441.37 / -5691.42 / 421.83 | 2462.76 / -5598.19 / 550.22 |
|
||||
| 5134 | Ebon Hold (E.K.) Top East Target | 0 – Eastern Kingdoms | fehlt/unvollständig | 2451.50 / -5641.27 / 546.10 | 2448.02 / -5655.82 / 420.64 |
|
||||
| 5135 | Ebon Hold (E.K.) Middle -> Bottom Target | 0 – Eastern Kingdoms | fehlt/unvollständig | 2427.67 / -5548.64 / 542.09 | 2435.77 / -5610.34 / 366.82 |
|
||||
| 5136 | Ebon Hold (E.K.) Middle -> Bottom Target | 0 – Eastern Kingdoms | fehlt/unvollständig | 2505.39 / -5563.90 / 542.09 | 2435.77 / -5610.34 / 366.82 |
|
||||
| 5137 | Ebon Hold (E.K.) Bottom -> Middle Target | 0 – Eastern Kingdoms | fehlt/unvollständig | 2419.03 / -5622.17 / 542.07 | 2472.90 / -5530.64 / 420.64 |
|
||||
| 5138 | Ebon Hold (E.K.) Bottom -> Ground Target | 0 – Eastern Kingdoms | fehlt/unvollständig | 2486.99 / -5636.42 / 542.14 | 2415.16 / -5733.04 / 153.92 |
|
||||
| 5273 | Dalaran Well teleporter | 571 – Northrend | fehlt/unvollständig | 5886.27 / 651.45 / 640.00 | 5878.56 / 666.42 / 615.29 |
|
||||
| 5326 | Arena - Dalaran Arena - Teleport Target | 617 – Dalaran Sewers | fehlt/unvollständig | 1330.00 / 800.00 / -10.00 | 1291.57 / 792.97 / 8.11 |
|
||||
| 5328 | Arena - Dalaran Arena - Teleport Target | 617 – Dalaran Sewers | fehlt/unvollständig | 1289.72 / 736.61 / 3.16 | 1291.57 / 792.97 / 8.11 |
|
||||
| 5329 | Arena - Dalaran Arena - Teleport Target | 617 – Dalaran Sewers | fehlt/unvollständig | 1292.05 / 845.27 / 5.63 | 1291.57 / 792.97 / 8.11 |
|
||||
| 5330 | Arena - Dalaran Arena - Teleport Target | 617 – Dalaran Sewers | fehlt/unvollständig | 1238.08 / 780.11 / 3.16 | 1291.57 / 792.97 / 8.11 |
|
||||
| 5331 | Arena - Dalaran Arena - Teleport Target | 617 – Dalaran Sewers | fehlt/unvollständig | 1348.50 / 792.83 / 3.82 | 1291.57 / 792.97 / 8.11 |
|
||||
| 5447 | Arena - Orgrimmar - Teleport Target 3 | 618 – The Ring of Valor | fehlt/unvollständig | 801.66 / -278.86 / -24.00 | 763.37 / -284.29 / 28.28 |
|
||||
| 5740 | Halls of Reflection (The Impenetrable Door) | 668 – Halls of Reflection | fehlt/unvollständig | 5361.10 / 2061.02 / 707.70 | 5354.01 / 2053.53 / 707.70 |
|
||||
| 5879 | Firelands Forgeworks (Exit) | 1 – Kalimdor | fehlt/unvollständig | 5042.17 / -2026.51 / 1148.98 | 5036.41 / -2045.99 / 1368.39 |
|
||||
| 5880 | Firelands Forgeworks (Enterance) | 1 – Kalimdor | fehlt/unvollständig | 5030.84 / -2036.30 / 1378.09 | 5029.45 / -2029.36 / 1148.97 |
|
||||
| 5893 | Hyjal - Teleport from Seat of the Prophets | 1 – Kalimdor | fehlt/unvollständig | 4320.92 / -3288.20 / 1036.05 | 3948.62 / -2818.22 / 618.75 |
|
||||
| 5895 | Hyjal - Teleport From Sulfuron Spire | 1 – Kalimdor | fehlt/unvollständig | 3943.90 / -2807.49 / 618.75 | 4316.05 / -3282.51 / 1035.53 |
|
||||
| 5926 | The Lost Isles - Volcanoth's Lair - Teleport Target - Phase 8+ | 659 – Lost Isles Volcano Eruption | fehlt/unvollständig | 1429.99 / 1233.86 / 124.60 | 1172.59 / 1094.79 / 119.23 |
|
||||
| 5931 | The Crucible of Flame (Enterance) | 1 – Kalimdor | fehlt/unvollständig | 4664.00 / -3686.81 / 956.63 | 4679.10 / -3675.53 / 696.45 |
|
||||
| 5937 | The Crucible of Flame (Exit) | 1 – Kalimdor | fehlt/unvollständig | 4678.15 / -3682.77 / 696.45 | 4653.99 / -3688.44 / 955.26 |
|
||||
| 6321 | Badlands 4.x - Cliff Teleport Target | 0 – Eastern Kingdoms | fehlt/unvollständig | -6518.68 / -4604.42 / 29.23 | -6547.88 / -4443.24 / 332.08 |
|
||||
| 6322 | Badlands 4.x - Dock Teleport Target | 0 – Eastern Kingdoms | fehlt/unvollständig | -6551.28 / -4446.46 / 332.83 | -6519.82 / -4608.52 / 28.48 |
|
||||
|
||||
Die Namen stammen aus der Teleporttabelle. Sie belegen keine universelle Begehbarkeit oder Freigabe für alle Spieler. Quests, Fraktion, Instanzstatus und Skriptbedingungen müssen vor einem Import geprüft werden.
|
||||
|
||||
Maschinenlesbare Daten einschließlich des vollständigen Node-/Kantenstatus: [same-map-portals.json](same-map-portals.json).
|
||||
@@ -0,0 +1,107 @@
|
||||
# Erste Schiffsverbindung: Sturmwind – Rut’theran
|
||||
|
||||
Auswahl des Nutzers: Schiff ab Sturmwind. Erster Test mit **The Bravery**,
|
||||
Spawn **2**, Entry **176310**. Die Datenbankbeschreibung nennt noch Auberdine;
|
||||
die tatsächlichen nativen Halte liegen bei Sturmwind und südlich des
|
||||
Rut’theran-Teleportziels. Für die Aufnahmen ist der echte Anleger maßgeblich.
|
||||
|
||||
Live über `clientless transport services 2` geprüft:
|
||||
|
||||
| Halt | Karte | Fahrzeugursprung X / Y / Z | Haltezeit |
|
||||
| --- | --- | --- | --- |
|
||||
| Sturmwind | 0 | -8650.719 / 1346.051 / 0.000 | 60 s |
|
||||
| Kalimdor, Anleger bei Rut’theran | 1 | 8162.587 / 1005.365 / 0.000 | 60 s |
|
||||
|
||||
Umlaufzeit: 235782 ms, zwei Halte und zwei native Pfadabschnitte. Diese
|
||||
Koordinaten bezeichnen den Schiffsrumpf-Ursprung, nicht den Groundnode.
|
||||
|
||||
## Aufnahme in Sturmwind
|
||||
|
||||
Am festen Boden unmittelbar vor dem Übergang zum Schiff positionieren:
|
||||
|
||||
```text
|
||||
.clientless node add ship_sw_br ship
|
||||
.clientless transport near 50
|
||||
```
|
||||
|
||||
Bei frisch angedocktem Schiff Spawn 2 beginnen, normal an Bord zu einem
|
||||
sicheren ebenen Deckplatz laufen und dort speichern:
|
||||
|
||||
```text
|
||||
.clientless transport record start ship_sw_br 2
|
||||
.clientless transport record stop bravery_center
|
||||
```
|
||||
|
||||
## Aufnahme auf Kalimdor
|
||||
|
||||
Mit demselben Schiff mitfahren. Am Ziel aussteigen und direkt vor dem
|
||||
Übergang auf festem Boden den gemeinsamen Haltestellen-Node erstellen:
|
||||
|
||||
```text
|
||||
.clientless node add ship_ruttheran_br ship
|
||||
```
|
||||
|
||||
Während eines vollständigen Halts desselben Schiffs erneut an Bord und zum
|
||||
gleichen lokalen Stehplatz gehen:
|
||||
|
||||
```text
|
||||
.clientless transport record start ship_ruttheran_br 2
|
||||
.clientless transport record stop bravery_center
|
||||
```
|
||||
|
||||
Danach Aufnahmen prüfen und Verbindung anlegen:
|
||||
|
||||
```text
|
||||
.clientless transport stops
|
||||
.clientless transport link ship_sw_br ship_ruttheran_br ship 100 both
|
||||
```
|
||||
|
||||
Der vorläufige Graphkostenwert 100 dient dem isolierten Transporttest. Erst
|
||||
nach funktioneller Abnahme und Kostenkalibrierung konkurrierende Fernrouten
|
||||
bewerten. Die Landanschlüsse zum restlichen Graphen separat aufnehmen;
|
||||
keine zusätzlichen Ein-/Ausgangsnodes für denselben Anleger erstellen.
|
||||
|
||||
## Abnahmekriterien
|
||||
|
||||
Nach zwei kompatiblen echten Übergangsaufnahmen Arthoran an `ship_sw_br`
|
||||
positionieren und `travel_to_node` nach `ship_ruttheran_br` ausführen. Prüfen:
|
||||
Warten, Boarding, Passenger, nativer Kartenwechsel 0→1 mit vollständigem
|
||||
Clientless-ACK, neue Transportkopie bei identischer Spawn-ID, Ausstieg,
|
||||
Landnode erreicht. Danach Gegenrichtung sowie Anschluss an Groundroute.
|
||||
|
||||
Die generische Typ-15-Unterstützung und der zusätzliche Regressionstest für
|
||||
den Kartenwechsel sind vorhanden. Stand der echten Schiffsfahrten siehe
|
||||
Live-Prüfung unten. Danach folgt der Zeppelin; Fahrstühle sind zurückgestellt.
|
||||
|
||||
## Live-Prüfung am 26.10.2026
|
||||
|
||||
Beide Übergänge wurden durch den GM aufgenommen und geladen:
|
||||
|
||||
- `ship_sw_br`: 11 Punkte, 17,72 Yards, Karte 0.
|
||||
- `ship_ruttheran_br`: 4 Punkte, 7,76 Yards, Karte 1. Node-Typ bei Anlage
|
||||
`ground`; der Executor verwendet den Fahrtkantentyp `ship`.
|
||||
- Beide Spawn 2, Entry 176310 und Stand `bravery_center`.
|
||||
|
||||
Die native Kompatibilitätsprüfung bestand; bidirektionale Kante 1743 wurde
|
||||
angelegt. Hinfahrt ohne Recovery: Waiting 09:15:04, Boarding 09:16:26,
|
||||
Riding/Passenger 09:16:31, Leaving 09:18:23, Arrived auf Land 09:18:25.
|
||||
Die nächste Planung begann nachweislich auf Karte 1.
|
||||
|
||||
Rückfahrt wurde ohne Logout per `.clientless luareload 26` um 09:32:29
|
||||
gestartet. Der Planer wählte Kante 1743 mit `reverse=1`. Boarding 09:34:06,
|
||||
Riding/Passenger 09:34:07, Leaving 09:36:05, Arrived auf Land in Sturmwind
|
||||
09:36:08. Auch die Rückfahrt wurde ohne Recovery abgeschlossen. Beide
|
||||
Richtungen nutzten die normalen Schiffs- und Passenger-Mechaniken inklusive
|
||||
Kartenwechsel. Es wurden während der Reisen keine Charakterpositionen als
|
||||
Test-Abkürzung manipuliert.
|
||||
|
||||
Worldserver-PID 37385 blieb während beider Fahrten unverändert. Arthoran
|
||||
blieb nach Abschluss online am Sturmwind-Anleger. Das Hinreise-Lua wurde
|
||||
auf der Festplatte wiederhergestellt, ohne eine weitere Aktion auszuführen.
|
||||
Die separat vom GM aufgezeichneten Land-/Portalanschlüsse Richtung Darnassus
|
||||
sind noch nicht Bestandteil dieses isolierten Seestrecken-Tests.
|
||||
|
||||
Ausgangsposition und vorheriges Lua liegen unter
|
||||
`/tmp/vcore-ship-ride.voj3k_8t`. Vorwärts-Lua ebenfalls dort gesichert.
|
||||
Der Nutzer untersagte für diese Testphase Shutdown und Neustart bis zu einer
|
||||
ausdrücklichen Freigabe; der laufende Server wurde durchgehend beibehalten.
|
||||
@@ -0,0 +1,92 @@
|
||||
# Sturmwind: Magierturm-Übergänge 702/704
|
||||
|
||||
Stand 26.10.2026. Auf dem lokalen Testserver gezielt importiert: vier aktive
|
||||
Portalnodes und zwei gerichtete Portalkanten. Keine Änderungen an nativen
|
||||
AreaTriggern, Teleportzielen oder interaktiven Rückreiseportalen.
|
||||
|
||||
| Node | Karte | X | Y | Z | Funktion |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| portal_at_702_source | 0 | -9020.670 | 890.104 | 29.6207 | unterer Eingang, Anlaufpunkt |
|
||||
| portal_at_702_target | 0 | -9014.940 | 873.326 | 148.616 | obere Ankunft |
|
||||
| portal_at_704_source | 0 | -9017.370 | 877.299 | 148.616 | oberer Ausgang, Anlaufpunkt |
|
||||
| portal_at_704_target | 0 | -9016.970 | 885.436 | 29.6207 | untere Ankunft |
|
||||
|
||||
Kanten: 1748 (702_source → 702_target) und 1749
|
||||
(704_source → 704_target), jeweils Kosten 1, unidirektional.
|
||||
|
||||
## Warum der reine Import nicht reicht
|
||||
|
||||
Die nativen Trigger sind gedrehte Boxen. Ihr Mittelpunkt liegt über dem Boden:
|
||||
702 bei Z=34 mit Höhe 11, 704 bei Z=152 mit Höhe 8. Der Importer übernimmt
|
||||
diesen Mittelpunkt als Source-Node. MMAP konnte insbesondere den Mittelpunkt
|
||||
von 704 nicht erreichen; der Planer wich auf einen unvollständigen langen
|
||||
Laufweg aus. Der erste Test wurde deshalb gestoppt.
|
||||
|
||||
Nur die Graph-Anlaufhöhen wurden innerhalb der Boxen auf die jeweilige Höhe
|
||||
der gegenüberliegenden nativen Ankunft gesetzt. X/Y und die Zielnodes bleiben
|
||||
unverändert. Die tatsächliche Zugehörigkeit zum Trigger wird weiterhin mit
|
||||
`IsInAreaTriggerRadius` geprüft; anschließend laufen normaler AreaTrigger-
|
||||
Handler, Clientless-ACK und Zielprüfung. Keine künstliche Portalabkürzung.
|
||||
|
||||
Reproduktion im lokalen Testsystem:
|
||||
|
||||
1. Konsole: `clientless portal import 702`, danach `clientless portal import 704`.
|
||||
2. `sql/custom/world/2026_10_26_01_clientless_stormwind_mage_tower.sql` auf die
|
||||
lokale Test-Weltdatenbank anwenden.
|
||||
3. Ingame `.clientless travelreload` oder sauberer Testserver-Neustart.
|
||||
|
||||
**Ein erneuter Portalimport überschreibt die kalibrierten Anlaufhöhen.**
|
||||
Danach die SQL-Kalibrierung erneut anwenden und den Graph neu laden. Der
|
||||
Bulk-Import überspringt gleichkartige Übergänge weiterhin.
|
||||
|
||||
## Live-Verifikation
|
||||
|
||||
Arthoran wurde ausgeloggt und nach Sicherung seiner Ausgangsdaten am nativen
|
||||
oberen Ziel von 702 positioniert. Diese Ausgangsposition simuliert eine
|
||||
Ankunft im oberen Bereich; ein interaktives Rückreiseportal selbst wurde
|
||||
nicht benutzt. Während der folgenden Reisen keine Positionsmanipulation:
|
||||
|
||||
- 11:51:06: oberer Ankunftspunkt → normaler MMAP-Anlauf → Trigger 704 → unten.
|
||||
- 11:51:07: untere Ankunft → normaler MMAP-Anlauf → Trigger 702 → oben.
|
||||
- 11:51:08: erneuter MMAP-Anlauf → Trigger 704 → unten.
|
||||
|
||||
Alle drei Übergänge: `EDGE_COMPLETE`, `arrivalError=0.00`, `recovery=0`.
|
||||
Die Kalibrierungs-SQL wurde anschließend zweimal erfolgreich angewendet;
|
||||
vier aktive Nodes und beide aktiven gerichteten Kanten wurden ausgelesen.
|
||||
|
||||
## Noch fehlender Groundanschluss
|
||||
|
||||
Der anschließende Versuch von unten nach `stormwind_gate_inner` fand keinen
|
||||
durchgängig validierten MMAP-Weg. Nach mehreren Teilanläufen endete er
|
||||
`TRAVEL_PARTIAL`, zuletzt nach einem erneuten Übergang 702. Damit ist die
|
||||
Weiterreise in den bestehenden Stadtgraph **noch nicht erfolgreich getestet**.
|
||||
Es wurden keine erfundenen Groundkanten oder geraden Wege durch Geometrie
|
||||
angelegt. Auch eine einzelne durchgehende Reise von einer beliebigen oberen
|
||||
Rückportal-Ankunft zu einem Stadtziel ist noch nicht abgenommen.
|
||||
|
||||
Nächster Schritt: mit dem GM eine echte Laufroute ab der unteren Ankunft
|
||||
`portal_at_704_target` zu einem bestehenden erreichbaren Stadtnode aufnehmen,
|
||||
zum Beispiel `stormwind_gate_inner`:
|
||||
|
||||
```text
|
||||
.clientless route start portal_at_704_target stormwind_gate_inner
|
||||
```
|
||||
|
||||
Am unteren Zielnode beginnen, den normalen begehbaren Weg bis zum Zielnode
|
||||
laufen und dort `.clientless route stop` ausführen. Der Recorder speichert
|
||||
bidirektional und lädt den Graph neu. Danach Arthoran in einem einzigen
|
||||
Reiseauftrag vom oberen Ankunftsbereich zum Stadtnode und zurück testen.
|
||||
Für den Weg hinein ist zusätzlich der Anschluss an `portal_at_702_source`
|
||||
zu prüfen; eine Aufnahme, die versehentlich durch einen Portaltrigger führt,
|
||||
ist kein durchgehender Groundweg.
|
||||
|
||||
## Abschlusszustand
|
||||
|
||||
Keine Coreänderung, daher kein neuer Build oder Binärinstall erforderlich.
|
||||
Testserver einmal sauber neu gestartet; aktuelle Ready-Meldung, Prozess 44833
|
||||
und Listener 8085 geprüft. Arthoran nach dem Test ausgeloggt, seine vorherige
|
||||
Position und das vorherige Lua-Skript wiederhergestellt.
|
||||
Sicherungen und Testlogs: `/tmp/vcore-mage-test.ntl3z51g`.
|
||||
Die Bestandsaufnahme `same-map-portals.md/json` bleibt als historischer
|
||||
Vorherstand erhalten; mit 702/704 sind nun vier gleichkartige Übergänge aktiv,
|
||||
56 der ursprünglich 58 zusätzlich fehlenden Übergänge bleiben offen.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Allgemeiner Lifecycle animierter GameObject-Transporte
|
||||
|
||||
## Ursache
|
||||
|
||||
Die Tiefenbahn besteht aus sechs `GAMEOBJECT_TYPE_TRANSPORT`-GameObjects
|
||||
(Typ 11). Drei Wagen besitzen ihre Datenbank-Spawnposition in Eisenschmiede,
|
||||
drei in Sturmwind. Diese GameObjects werden beim Erstellen bereits über
|
||||
`setActive(true)` für Aktualisierung und einen Unload-Lock ihres Spawn-Grids
|
||||
registriert. Vorher müssen sie jedoch vom normalen Grid-Loader erzeugt werden.
|
||||
|
||||
Bei ausschließlich in Sturmwind anwesenden Spielern blieb das entfernte
|
||||
Spawn-Grid ungeladen. Der dort startende Zug existierte daher serverseitig noch
|
||||
nicht. Das betrifft Netzwerkspieler ebenso wie clientlose Spieler. Ein Besuch
|
||||
in Eisenschmiede lud die fehlenden Wagen und ließ den Fehler bis zum nächsten
|
||||
Neustart verschwinden.
|
||||
|
||||
## Änderung
|
||||
|
||||
`ObjectMgr::LoadGameObjects` indexiert nach Abschluss der vorhandenen
|
||||
Validierungen reguläre Typ-11-Spawns nach Karte. Ereignisgebundene Spawns
|
||||
bleiben außerhalb dieses Index und behalten die bisherige Ereignisverwaltung.
|
||||
|
||||
`MapManager` ruft nach Initialisierung der Respawn-Daten beziehungsweise der
|
||||
Instanz-/Battleground-Daten `Map::LoadTransportGameObjectGrids` auf. Diese
|
||||
Funktion lädt ausschließlich die Spawn-Grids der indexierten Transporte für die
|
||||
jeweilige Karte und Spawnmaske. Das Laden erfolgt über `EnsureGridLoaded`:
|
||||
vorhandene Pools, Spawn-Gruppen, Respawn- und Phasenmechaniken gelten weiter.
|
||||
Bereits geladene Grids erzeugen keine zusätzlichen Objektinstanzen.
|
||||
|
||||
Die Änderung verwendet keine CPC-, AdventureBot- oder TravelGraph-Komponenten.
|
||||
Die getrennt verwalteten Typ-15-Transporte (Schiffe/Zeppeline) bleiben im
|
||||
vorhandenen Transport-Lifecycle. Es werden keine Transportfahrpläne verändert.
|
||||
|
||||
Im Testdatenbestand liegen zwischen 1 und 47 Typ-11-Spawns pro betroffener
|
||||
Karte, sechs davon auf Karte 369. Benachbarte Wagen teilen sich Spawn-Grids.
|
||||
Durch das frühe Laden werden auch die normalen Inhalte dieser Grids früher
|
||||
erzeugt; ein vollständiges Vorladen sämtlicher Kartengrids erfolgt nicht.
|
||||
|
||||
## Prüfung
|
||||
|
||||
`python3 tests/clientless/test_transport_grid_loading.py` prüft die tatsächlichen
|
||||
neuen Indexierungs- und Ladeblöcke mit deterministischen Core-Doubles:
|
||||
beide Stationen ohne Spieler, wiederholtes Laden, andere Karten,
|
||||
Spawnmasken, entfernte/verschobene Spawns und Ausschluss ereignisgebundener
|
||||
Objekte sowie Typ-15-Transporte.
|
||||
|
||||
Für den entscheidenden Live-Test ist ein frischer Worldserverprozess nötig.
|
||||
Danach darf zunächst ausschließlich die Sturmwindseite betreten werden.
|
||||
Alle sechs Spawns 18802–18807 müssen dort bereits über `transport inspect`
|
||||
auflösbar sein. Zwei zeitlich getrennte Zustandsaufnahmen müssen Bewegung
|
||||
beider Zuggruppen zeigen. Das erstmalige manuelle Betreten der Gegenseite
|
||||
wäre für diesen Test eine unzulässige Vorbedingung, da es den ursprünglichen
|
||||
Fehler verdecken würde.
|
||||
|
||||
## Live-Ergebnis, 26.10.2026
|
||||
|
||||
Build und Installation bestanden, Build-ID
|
||||
`28fa55f1b9604a57da4616b23a195c117d2fcc82`. Neuer Worldserverprozess: PID 36683;
|
||||
Ready-Meldung und Port 8085 geprüft. Vor dem Test war kein GM eingeloggt.
|
||||
Arthoran wurde ausschließlich an `tram_sw` mit einem untätigen Lua-Skript
|
||||
eingeloggt; ein Besuch am anderen Ende entfiel vollständig.
|
||||
|
||||
Die neue Core-Diagnostik meldete für Karte 369 drei geladene Spawn-Grids und
|
||||
sechs Transportkandidaten. Alle sechs Spawns 18802–18807 waren auflösbar.
|
||||
Zwei mindestens 20 Sekunden getrennte Aufnahmen bestätigten für jeden Wagen
|
||||
geänderte Position und Animationsfortschritt. Beide Zuggruppen bewegten sich.
|
||||
Ergebnis und Konsolenausgaben:
|
||||
`/tmp/vcore-tram-ride.1i65g2dm/cold-grid-test-result.json` und
|
||||
`cold-grid-test-console.txt` im selben Verzeichnis. Das vorherige Reise-Lua wurde
|
||||
anschließend wiederhergestellt; Arthoran blieb online und untätig in Sturmwind.
|
||||
Eine visuelle Abnahme durch einen normalen WoW-Client bleibt Aufgabe des GM.
|
||||
|
||||
Separater offener Befund: Beim vorhergehenden Shutdown des noch unveränderten
|
||||
Servers (PID 30922, alter Build) erschien `Object.h:448 in GetMap ASSERTION FAILED:
|
||||
m_currMap`. Der Prozess endete. Diese Assertion trat vor Installation des
|
||||
Grid-Fixes auf; ihre Ursache ist noch nicht festgestellt. Vorherige Logs liegen
|
||||
unter `before-grid-fix-restart` im obigen Testverzeichnis. Die Konsolenmeldung
|
||||
enthielt keinen auswertbaren Stacktrace.
|
||||
@@ -0,0 +1,330 @@
|
||||
# Clientless Transport: Aufnahme und Test
|
||||
|
||||
Die Integration verwendet reguläre Trinity-Transporte (`TransportBase`). Eine
|
||||
Haltestelle hat genau einen TravelGraph-Node für Ein- und Ausstieg. Der Übergang
|
||||
auf das Fahrzeug wird getrennt von den Groundkanten aufgenommen. Es werden
|
||||
keine Schiffe, Aufzüge oder Bahnen teleportiert oder für den Bot angehalten.
|
||||
|
||||
## Datenbank
|
||||
|
||||
Die zusätzliche Migration liegt unter
|
||||
`sql/custom/world/2026_10_25_00_clientless_transport.sql`. Sie wird ausdrücklich
|
||||
auf die Test-Weltdatenbank angewendet. Die drei neuen Tabellen sind:
|
||||
|
||||
- `vcore_clientless_transport_stop`: Haltestelle, Fahrzeug-Entry, Spawn und Dockpose.
|
||||
- `vcore_clientless_transport_point`: geordnete Übergangspunkte mit Welt-/Transportbezug.
|
||||
- `vcore_clientless_transport_stand`: benannte Stehplätze relativ zum Fahrzeugmodell.
|
||||
|
||||
Bestehende Nodes und Groundkanten bleiben erhalten. Neue Fahrtverbindungen
|
||||
werden in `vcore_clientless_travel_edge` gespeichert. Ohne zwei gültige,
|
||||
kompatible Aufnahmen wird eine Transportkante vom Graphen nicht geladen.
|
||||
Ein Reload kopiert keine Änderungen in eine bereits laufende Fahrt hinein.
|
||||
|
||||
## 1. Haltestelle und Fahrzeug bestimmen
|
||||
|
||||
Schiffe und Zeppeline können unabhängig vom Aufenthaltsort des GM über die
|
||||
nativen Routendaten untersucht werden:
|
||||
|
||||
```text
|
||||
.clientless transport services
|
||||
.clientless transport services 7
|
||||
```
|
||||
|
||||
Die Liste zeigt Spawn, Entry, Modellname, Umlaufzeit, Zahl der geplanten Halte
|
||||
und Pfadabschnitte. Mit Spawn-ID werden zusätzlich die tatsächlichen
|
||||
Dockpositionen, Karten, Ankunftszeitpunkte im Umlauf und Haltezeiten ausgegeben.
|
||||
Diese Daten stammen aus den geladenen Transportpfaden, nicht aus möglicherweise
|
||||
veralteten `transports.name`-Beschreibungen. Die Dockposition bezeichnet den
|
||||
Fahrzeugursprung; den Haltestellen-Node weiterhin auf begehbarem festen Boden
|
||||
am tatsächlichen Land-Deck-Übergang aufnehmen. Reine Patrouillen ohne planmäßige
|
||||
Halte sind keine automatisch geeigneten Passagierverbindungen.
|
||||
|
||||
Beide Befehle sind auch ohne eingeloggten Bot in der Konsole verfügbar.
|
||||
Die Spawn-ID bleibt auf beiden Karten dieselbe, obwohl der Core beim
|
||||
Kartenwechsel eine andere Transportkopie verwendet. Diese kopiert der Recorder
|
||||
nicht als zweites Fahrzeug in den Graphen.
|
||||
|
||||
Mit dem GM auf festem Boden direkt vor der gewünschten Einstiegsstelle stehen:
|
||||
|
||||
```text
|
||||
.clientless node add lift_lower elevator
|
||||
.clientless transport near 30
|
||||
```
|
||||
|
||||
Für andere Verkehrsmittel den Nodetyp `tram`, `ship` oder `zeppelin` verwenden.
|
||||
`near` zeigt unter anderem Entry, Spawn-ID, Weltposition, `stopped`, Periode,
|
||||
Fortschritt und verbleibende Haltezeit. Die **Spawn-ID**, nicht die Entry-ID,
|
||||
bindet die Aufnahme an das konkrete Fahrzeug bzw. den konkreten Wagen.
|
||||
|
||||
```text
|
||||
.clientless transport inspect 204666
|
||||
```
|
||||
|
||||
In der Konsole wird ein online befindlicher CPC-Player als Beobachter angegeben:
|
||||
|
||||
```text
|
||||
clientless transport inspect 204666 26
|
||||
clientless transport near 30 26
|
||||
```
|
||||
|
||||
Die vom Transportrecorder verwendeten Node-Koordinaten können auch über die
|
||||
Konsole geprüft werden:
|
||||
|
||||
```text
|
||||
clientless transport node tram_sw
|
||||
clientless transport node tram_if
|
||||
```
|
||||
|
||||
Das Objekt muss auf der Map des Beobachters geladen sein. `remainingPause` wird
|
||||
in Millisekunden ausgegeben; `4294967295` bezeichnet einen Halt ohne bekannte
|
||||
zeitliche Begrenzung. Ein GoState allein wird nicht als Ankunft interpretiert.
|
||||
|
||||
## 2. Geeigneten Stehplatz speichern (optional vorab)
|
||||
|
||||
Normal an Bord gehen, einen sicheren, ebenen Platz wählen und dort stehen:
|
||||
|
||||
```text
|
||||
.clientless transport stand add center
|
||||
.clientless transport stand list
|
||||
```
|
||||
|
||||
Stehplätze sind lokal zum Transportmodell gespeichert und bleiben bei Bewegung
|
||||
und Kartenwechseln an derselben Stelle des Fahrzeugs. Sie können auch während
|
||||
der Fahrt aufgenommen werden. Nicht fliegen oder fallen. Derselbe Name wird
|
||||
nicht still überschrieben. Andere Namen erlauben weitere Standorte.
|
||||
|
||||
```text
|
||||
.clientless transport stand list 4170
|
||||
.clientless transport stand remove 4170 unused_name
|
||||
```
|
||||
|
||||
`list <entry>` und `remove <entry> <name>` sind konsolenfähig. Ein von einer
|
||||
Haltestelle verwendeter Standname kann nicht entfernt werden, bevor deren
|
||||
Aufnahme ausdrücklich entfernt wurde.
|
||||
|
||||
## 3. Land -> Deck aufnehmen
|
||||
|
||||
Wieder am gemeinsamen Haltestellen-Node auf festem Boden stehen. Auf die nächste
|
||||
Ankunft warten, damit ein vollständiges Haltefenster verfügbar ist:
|
||||
|
||||
```text
|
||||
.clientless transport record start lift_lower 204666
|
||||
```
|
||||
|
||||
Nun normal zum Fahrzeug gehen, es betreten und bis zum gewünschten Stehplatz
|
||||
laufen. Die Aufnahme erfolgt automatisch alle 100 ms mit mindestens 0,75 Yards
|
||||
Punktabstand. Der Wechsel vom Land zum Passenger wird automatisch markiert.
|
||||
|
||||
```text
|
||||
.clientless transport record mark
|
||||
.clientless transport record info
|
||||
.clientless transport record stop center
|
||||
```
|
||||
|
||||
`mark` ist optional und erzwingt einen zusätzlichen Punkt. `stop center`
|
||||
speichert den Übergang und legt bei Bedarf den Stand `center` an. Existiert er
|
||||
bereits, muss der GM höchstens 1,5 Yards davon entfernt stehen.
|
||||
|
||||
Die Aufnahme muss während desselben Halts beendet werden. Abfahrt,
|
||||
Transportwechsel, Verlassen des Fahrzeugs nach bereits erfolgtem Einstieg,
|
||||
Positionssprünge über 8 Yards, Flug oder ein Übergang über 60 Yards machen sie
|
||||
ungültig. Vorhandene Aufnahmen werden nicht still überschrieben.
|
||||
|
||||
```text
|
||||
.clientless transport record cancel
|
||||
```
|
||||
|
||||
Bei einem Fehler bleibt die Diagnose über `record info` sichtbar. Erst mit
|
||||
`cancel` verwerfen und beim nächsten Halt neu beginnen. Jeder GM hat einen
|
||||
eigenen Aufnahmebuffer; Logout verwirft nur dessen ungespeicherte Aufnahme.
|
||||
|
||||
## 4. Gegenhaltestelle aufnehmen
|
||||
|
||||
Mit genau demselben Fahrzeug/Wagen zur Gegenhaltestelle fahren. Aussteigen und
|
||||
auf festem Boden den gemeinsamen Node anlegen:
|
||||
|
||||
```text
|
||||
.clientless node add lift_upper elevator
|
||||
```
|
||||
|
||||
Bei einer folgenden Ankunft von diesem Node erneut an Bord zum gleichen lokalen
|
||||
Stand laufen und aufnehmen:
|
||||
|
||||
```text
|
||||
.clientless transport record start lift_upper 204666
|
||||
.clientless transport record stop center
|
||||
.clientless transport stops
|
||||
```
|
||||
|
||||
Die Deck-Endpunkte beider Übergänge dürfen höchstens 3 Yards auseinanderliegen.
|
||||
Damit wird keine unbekannte längere Querung des Decks zwischen zwei verschiedenen
|
||||
Standorten benötigt. Der aufgezeichnete Übergang wird beim Ausstieg umgekehrt.
|
||||
Deshalb einen Weg aufnehmen, der in beiden Laufrichtungen begehbar ist.
|
||||
|
||||
Bei einer Tiefenbahnaufnahme denselben Wagen an beiden Bahnhöfen verwenden.
|
||||
Die sechs Wagen haben Entries 176080..176085 und Spawn-IDs 18802..18807.
|
||||
Die Haltefenster betragen ungefähr 11..14 Sekunden; kurze Rampenwege sind
|
||||
entsprechend wichtig. Ein anderer Wagen ist eine andere Aufnahmeverbindung.
|
||||
|
||||
## 5. Fahrtkante anlegen
|
||||
|
||||
```text
|
||||
.clientless transport link lift_lower lift_upper elevator 100 both
|
||||
```
|
||||
|
||||
Der Kostenwert ist ein positiver **Graphkostenwert**, keine automatisch mit
|
||||
Groundlängen vergleichbare Sekundenzahl. Zum ersten isolierten Funktionstest
|
||||
ist der genaue Wert unkritisch; konkurrierende Fernrouten benötigen später eine
|
||||
gemeinsame Kostenkalibrierung. `both` ist optional und erzeugt eine bidirektionale
|
||||
Kante. Es darf nur verwendet werden, wenn das reale Verkehrsmittel beide
|
||||
Richtungen bedient. Ohne `both` entsteht nur die angegebene Fahrtrichtung.
|
||||
|
||||
Die Kante wird erst nach Prüfung beider Haltestellen angelegt. Gleicher Entry,
|
||||
gleicher Spawn, verschiedene Dockpositionen/Maps und kompatible Stehplätze sind
|
||||
notwendig. Die Typen `tram`/`elevator` gehören zu GameObjects des Typs 11,
|
||||
`ship`/`zeppelin` zu Typ 15.
|
||||
|
||||
Die Haltestellen benötigen wie andere Nodes eine Verbindung zum übrigen Netz:
|
||||
aufgenommene Groundkanten oder zulässige, vollständig geprüfte MMAP-Anschlüsse.
|
||||
|
||||
## 6. Botfahrt testen
|
||||
|
||||
Arthoran zunächst an der Abfahrt positionieren und clientlos einloggen. Das
|
||||
bestehende Login-Lua-Skript kann bereits andere Aufträge starten; für einen
|
||||
isolierten Transporttest dieses Skript vorher entsprechend einstellen, z.B.:
|
||||
|
||||
```lua
|
||||
queue_action("travel_to_node", {
|
||||
node = "lift_upper",
|
||||
hard_destination = true
|
||||
})
|
||||
```
|
||||
|
||||
Alternativ bei einem untätigen CPC-Player über Konsole oder Spielchat:
|
||||
|
||||
```text
|
||||
clientless transport travel 26 lift_upper
|
||||
clientless transport status 26
|
||||
clientless actioninfo 26
|
||||
```
|
||||
|
||||
`travel` hängt einen Auftrag an die reguläre CPC-Aktionswarteschlange an; es
|
||||
unterbricht keine bereits laufende andere Aktion. Die Zustände sind `Waiting`,
|
||||
`Boarding`, `Riding`, `Leaving`, `Arrived`, `Failed`. Während der Mitfahrt wird
|
||||
keine Groundnavigation gestartet. Native Kartenwechsel werden für CPC ohne
|
||||
Client-ACK abgeschlossen. Der nächste Graphschritt beginnt erst nach dem
|
||||
Aussteigen und Erreichen des gemeinsamen Zielnodes auf Land.
|
||||
|
||||
```text
|
||||
clientless transport cancel 26
|
||||
```
|
||||
|
||||
Abbruch auf Land stoppt die Fahrt. An Bord bleibt der Bot Passenger und steigt
|
||||
an einer sicheren aufgezeichneten Haltestelle aus, statt in der Luft oder im
|
||||
Wasser abgehängt zu werden. Ein harter Timeout meldet `Failed` und lässt eine
|
||||
noch vorhandene Passenger-Zuordnung bestehen; die Diagnose bleibt erhalten.
|
||||
|
||||
Alle Transport-Zustandswechsel und Aufnahmefehler stehen in
|
||||
`adventurebot-navigation.log`. `.clientless`-Spielchatbefehle und deren Antworten
|
||||
werden dort ebenfalls protokolliert; Konsolenantworten sind über tmux lesbar.
|
||||
|
||||
## Pflege
|
||||
|
||||
```text
|
||||
.clientless transport reload
|
||||
.clientless transport forget lift_lower
|
||||
```
|
||||
|
||||
`forget` entfernt nur die Übergangsaufnahme. Node, Groundkanten und benannte
|
||||
Stehplätze bleiben bestehen. Nach Verschieben des Nodes dessen Aufnahme neu
|
||||
anlegen. Fahrtkanten mit unvollständigen Daten werden nicht geplant.
|
||||
|
||||
## Verifikation und Grenzen
|
||||
|
||||
Automatisierte Zustands-/Recorder-Tests:
|
||||
|
||||
```text
|
||||
python3 tests/clientless/test_transport.py
|
||||
```
|
||||
|
||||
Diese verwenden die tatsächlichen Produktionsmethoden mit deterministischen
|
||||
Weltobjekt-Doubles. Sie prüfen unter anderem Haltefenster, Koordinatenrahmen,
|
||||
Warteverhalten, Passenger-Zuordnung, Ausstieg und Abbruch/Timeout. Sie beweisen
|
||||
keine Kollisionsfreiheit eines konkreten Decks und keine erfolgreiche Fahrt
|
||||
auf einem realen GameObject.
|
||||
|
||||
Nach dem Tiefenbahntest folgen ein Schiff mit Kartenwechsel und ein Zeppelin;
|
||||
Fahrstühle werden anschließend behandelt. Je Verbindung beide Fahrtrichtungen,
|
||||
verpasste Halte, Ein-/Ausstieg und Fortsetzung einer Groundroute prüfen. Logout/Serverneustart
|
||||
während einer Typ-11-Fahrt ist noch separat zu verifizieren; der bestehende
|
||||
Trinity-Loginpfad stellt vorrangig die Typ-15-Transportzuordnung wieder her.
|
||||
Die Recorder setzen einen normalen GM-Client für die echte Bewegung voraus;
|
||||
sie erzeugen keine synthetischen Deckpfade.
|
||||
|
||||
## Live-Test Sturmwind → Eisenschmiede, 26.10.2026
|
||||
|
||||
Arthoran (GUID 26) fuhr über Kante 1741 von `tram_sw` nach `tram_if` auf
|
||||
Wagen 18803. Navigationlog: Boarding 00:37:34, Riding mit `passenger=1`
|
||||
00:37:35, Leaving 00:38:38, Arrived mit `passenger=0` 00:38:42.
|
||||
Die Konsole meldete anschließend `travel-route-complete`. Das belegt die
|
||||
serverseitige Durchführung einer Richtung; Gegenrichtung und visuelle
|
||||
Ingame-Abnahme sind damit noch nicht bestätigt.
|
||||
|
||||
Vorbedingung/noch offener Fehler: Beim ersten Start ausschließlich an der
|
||||
Sturmwindseite war Spawn 18803 nicht geladen. Seine Datenbank-Spawnposition
|
||||
liegt auf der Eisenschmiedeseite (4.528, 8.435, 7.011). Ein kurzer Diagnose-Login
|
||||
Arthorans an `tram_if` lud das Spawn-Grid; danach war der aktive Wagen auch von
|
||||
Sturmwind aus verfügbar. Für den eigentlichen Fahrtest wurde Arthoran offline
|
||||
wieder an `tram_sw` positioniert. Während der Fahrt wurden keine Positionen per
|
||||
Datenbank oder Teleport verändert. Automatisches Laden des entfernten
|
||||
Transport-Spawn-Grids muss vor autonomem Betrieb ergänzt werden; der erfolgreiche
|
||||
Fahrtest nach manuellem Vorladen behebt diese Einschränkung nicht.
|
||||
|
||||
Testskript und vorherige Charakterposition gesichert unter
|
||||
`/tmp/vcore-tram-ride.1i65g2dm`.
|
||||
|
||||
Nachtrag: Der oben beschriebene Spawn-Grid-Fehler wurde anschließend im
|
||||
allgemeinen Map-Lifecycle behoben und mit einem frischen Prozess ausschließlich
|
||||
von Sturmwind aus geprüft. Alle sechs Wagen waren verfügbar und bewegten sich.
|
||||
Details und verbleibende Befunde: [transport-grid-lifecycle.md](transport-grid-lifecycle.md).
|
||||
|
||||
## Nächste Verbindungen: Schiff und Zeppelin
|
||||
|
||||
Aktuelle Nutzerauswahl: zuerst **Schiff ab Sturmwind**. Die konkrete Aufnahme
|
||||
für Spawn 2 und der Testablauf stehen in
|
||||
[ship-stormwind-ruttheran.md](ship-stormwind-ruttheran.md).
|
||||
|
||||
Geeignete erste Kandidaten aus der Testdatenbank:
|
||||
|
||||
- Spawn 7, Entry 20808, `The Maiden's Fancy`: Küstenschiff; tatsächliche Häfen
|
||||
über `services 7` prüfen. Der Kartenwechsel zwischen Kalimdor und den östlichen
|
||||
Königreichen prüft die Passenger-Übergabe zwischen Transportkopien.
|
||||
- Spawn 20, Entry 190549, `The Zephyr`: Zeppelin auf Kalimdor. Die vorhandene
|
||||
Transport-/SmartAI bleibt erhalten. Die Fahrt ergänzt den Lufttransport-Test
|
||||
ohne Kartenwechsel.
|
||||
|
||||
Die Recorder- und Link-Befehle bleiben dieselben. Beispiele mit nach Prüfung
|
||||
der Anlegestellen manuell aufgenommenen Nodes:
|
||||
|
||||
```text
|
||||
.clientless node add ship_ratchet ship
|
||||
.clientless transport record start ship_ratchet 7
|
||||
.clientless transport record stop maiden_center
|
||||
|
||||
.clientless node add ship_booty_bay ship
|
||||
.clientless transport record start ship_booty_bay 7
|
||||
.clientless transport record stop maiden_center
|
||||
.clientless transport link ship_ratchet ship_booty_bay ship 100 both
|
||||
```
|
||||
|
||||
Den ersten Stehplatz an der Gegenhaltestelle wieder exakt aufsuchen. Zwischen
|
||||
den beiden Node-Aufnahmen mit demselben Schiff mitfahren. `record start` beginnt
|
||||
jeweils auf festem Boden, `record stop` endet an Bord während desselben Halts.
|
||||
Pro physischer Anlegestelle weiterhin nur einen gemeinsamen Node verwenden;
|
||||
Landverbindungen zum übrigen TravelGraph getrennt als Groundkante erfassen.
|
||||
|
||||
Für den Zeppelin analog eigene Haltestellen-Keys, Fahrzeug-Spawn 20 und
|
||||
Kantentyp `zeppelin` verwenden. Schiffs- und Zeppelin-Verbindungen erst nach
|
||||
einer echten Fahrt als verifiziert betrachten. Die automatisierten Tests
|
||||
simulieren den nativen Kartenwechsel; sie ersetzen keinen echten Worldport-
|
||||
und Kollisions-/Sichtbarkeitstest.
|
||||
@@ -0,0 +1,151 @@
|
||||
# Interaktive Objektportale im TravelGraph
|
||||
|
||||
Stand 26.10.2026. Diese Erweiterung bildet einen normalen Klick auf ein
|
||||
festes Portal-GameObject als gerichtete TravelGraph-Kante `portal_use` ab.
|
||||
Die Nodes behalten den Typ `portal`. AreaTrigger-Portale (`portal_at_…`)
|
||||
und deren bestehender Import bleiben unverändert.
|
||||
|
||||
## Unterstützter erster Interaktionstyp
|
||||
|
||||
Unterstützt werden statisch gespawnte GameObjects vom Typ 22 (`SPELLCASTER`),
|
||||
deren direkt benutzter Zauber genau einen Teleporteffekt mit einem festen
|
||||
`TARGET_DEST_DB`-Ziel besitzt. Spawn, Objekt-Entry, Zauber und Ziel werden aus
|
||||
den geladenen nativen Core-/DB-Daten ermittelt.
|
||||
|
||||
Nicht jeder klickbare Teleporter erfüllt diese Voraussetzungen. NPC-Gossip,
|
||||
Gossip-Auswahl an Objekten, GOOBER-Skripte, ausgelöste Zauberketten und dynamisch
|
||||
gewählte Ziele benötigen weitere Interaktionsvarianten. Der Importer weist
|
||||
solche Fälle zurück, anstatt ein Ziel zu erraten. Beispielsweise hat der
|
||||
Shattrath-Teleporter zur Insel von Quel’Danas einen anders aufgebauten Zauber
|
||||
und ist nicht durch diesen direkten Import abgedeckt.
|
||||
|
||||
Vashj’ir mit seinen besonderen Wasserreiseangeboten bleibt gemäß
|
||||
Nutzerentscheidung eine spätere Erweiterung. Schwimmen/Tauchen gehört zur
|
||||
lokalen Navigation; Ruhesteine und eigene Teleportzauber sind nicht Teil
|
||||
dieser TravelGraph-Erweiterung.
|
||||
|
||||
## Einrichtung und gezielter Import
|
||||
|
||||
Einmalig die neue Tabelle auf der lokalen Test-Weltdatenbank anlegen:
|
||||
|
||||
```text
|
||||
sql/custom/world/2026_10_26_02_clientless_portal_use.sql
|
||||
```
|
||||
|
||||
Die Datei legt nur `vcore_clientless_portal_use` an; sie aktiviert keine
|
||||
Verbindung. Nach Installation des neuen Builds ein konkretes Objekt auswählen.
|
||||
Im Spiel hilft `.gobject near 30` bei der Suche nach vorhandenen Objekten.
|
||||
Benötigt wird die Datenbank-Spawn-ID, nicht die Objekt-Entry-ID.
|
||||
|
||||
Im GM-Spielchat:
|
||||
|
||||
```text
|
||||
.clientless portal object import <gameobjectSpawnId>
|
||||
```
|
||||
|
||||
Oder in der Testserver-Konsole ohne Punkt:
|
||||
|
||||
```text
|
||||
clientless portal object import <gameobjectSpawnId>
|
||||
```
|
||||
|
||||
Beispiel Dunkles Portal (Scherbenweltseite) → Sturmwind:
|
||||
|
||||
```text
|
||||
clientless portal object import 170
|
||||
```
|
||||
|
||||
Der Import erzeugt:
|
||||
|
||||
- `portal_go_170_source` an der nativen Objektposition.
|
||||
- `portal_go_170_target` am nativen Zauberziel.
|
||||
- Eine gerichtete Kante `portal_use`, Kosten 1, ohne automatische Rückrichtung.
|
||||
- Eine Bindung der Kanten-ID an Spawn 170, Entry 195139 und Zauber 17334.
|
||||
|
||||
Ein vorhandener Node oder eine vorhandene Kante wird nicht überschrieben.
|
||||
Insbesondere bleiben nachbearbeitete Anlaufpositionen und `enabled=0` erhalten.
|
||||
Die Transaktion kann wiederholt werden, ohne zusätzliche Nodes/Kanten zu
|
||||
produzieren. Der Import lädt Graph und Weltpunkte anschließend neu. Eine
|
||||
ungültige oder deaktivierte Verbindung wird ausdrücklich als nicht aktiv
|
||||
angezeigt; Details stehen im Navigationlog.
|
||||
|
||||
Rückreisen benötigen ein tatsächlich vorhandenes anderes Portal und eine
|
||||
eigene Definition. Ein Portal nach Sturmwind erzeugt keine Rückfahrt nach
|
||||
Shattrath. Der Graph verwirft `portal_use`-Kanten mit `bidirectional=1`, fehlender
|
||||
oder veralteter Bindung und einem unpassenden Zielnode.
|
||||
|
||||
Der zusätzlich untersuchte Shattrath-Spawn **83** ist im Testgraph deaktiviert:
|
||||
Sein nativer Zauber verwirft die Effekte bei der Sichtlinienprüfung. Das trat
|
||||
bei Level 20 und 60 sowie an zwei Anlaufpositionen auf. Es wurde keine native
|
||||
Sichtlinienprüfung umgangen und keine GO-/Zauberdefinition verändert. Ein
|
||||
Wiederholungsimport soll diese Deaktivierung erhalten.
|
||||
|
||||
## Anlauf und Reiseauftrag
|
||||
|
||||
Der Quellnode muss per normaler lokaler Navigation erreichbar sein und nahe
|
||||
genug am tatsächlichen Objekt liegen. Bei Bedarf den Source-Node mit einem
|
||||
GM auf einen begehbaren Punkt innerhalb der Interaktionsreichweite verschieben:
|
||||
|
||||
```text
|
||||
.clientless node move portal_go_170_source
|
||||
```
|
||||
|
||||
Ein Wiederholungsimport behält diese Anpassung bei. Falls der Source-Node
|
||||
anschließend über Groundwege angeschlossen werden muss, diese separat
|
||||
aufnehmen. Die Kante selbst enthält keine erfundenen Laufwege.
|
||||
|
||||
Ein normaler Lua-Reiseauftrag verwendet den Zielnode:
|
||||
|
||||
```lua
|
||||
queue_action("travel_to_node", {
|
||||
node = "portal_go_170_target",
|
||||
hard_destination = true
|
||||
})
|
||||
```
|
||||
|
||||
Der Executor prüft das konkrete gespawnte Objekt, Phase, Sichtbarkeit,
|
||||
Sperr-/Auswahlflags und die normale Interaktionsreichweite. Er stoppt die
|
||||
Bewegung, steigt gegebenenfalls vom Bodenreittier ab und ruft den normalen
|
||||
`CMSG_GAMEOBJ_USE`-Handler auf. Die native Objekt-AI, Gruppenprüfungen und
|
||||
Zauberbedingungen entscheiden weiterhin, ob der Klick funktioniert.
|
||||
Level- oder Questanforderungen werden durch den Executor nicht umgangen.
|
||||
|
||||
Nach dem Klick wartet der Bot auf einen bestätigten Clientless-Teleport.
|
||||
Ein Session-Zähler erfasst erfolgreich abgeschlossene Near-/Far-ACKs, auch
|
||||
wenn der allgemeine Session-Lifecycle den ACK vor dem nächsten Executor-Tick
|
||||
bearbeitet hat. Erst bei neuer Bestätigung, abgeschlossenem Teleport,
|
||||
richtiger Zielkarte und höchstens 5 Yards Zielfehler gilt die Kante als fertig.
|
||||
Nach 10 Sekunden ohne bestätigte Ankunft schlägt die Aktion fehl. Kein
|
||||
wiederholtes Klicken pro Tick, kein künstlicher Ersatzteleport.
|
||||
|
||||
Der Planner prüft derzeit nicht sämtliche individuellen Zauberbedingungen
|
||||
vorab. Eine geplante Verbindung kann deshalb bei der tatsächlichen Benutzung
|
||||
abgewiesen werden. Die Ablehnung ist kein erfolgreicher Reiseabschluss.
|
||||
|
||||
## Diagnose und Tests
|
||||
|
||||
```text
|
||||
.clientless actioninfo 26
|
||||
```
|
||||
|
||||
Navigationlog:
|
||||
|
||||
- `PORTAL_USE_START`: konkretes Objekt wurde an den Klick-Handler übergeben.
|
||||
- `EDGE_COMPLETE … edgeType='portal_use'`: Ankunft einschließlich ACK bestätigt.
|
||||
- `PORTAL_USE_FAILED`: Objekt/Spielerzustand ungeeignet oder Teleport nicht bestätigt.
|
||||
- `PORTAL_USE_BINDING_INVALID` / `PORTAL_USE_EDGE_UNAVAILABLE`: Definition beim Reload verworfen.
|
||||
|
||||
Automatisierte Regressionen:
|
||||
|
||||
```text
|
||||
python3 tests/clientless/test_portal_use.py
|
||||
python3 tests/clientless/test_portal_transfer.py
|
||||
```
|
||||
|
||||
Sie führen die tatsächlichen Produktionsmethoden mit Core-Doubles aus:
|
||||
Quellobjektprüfung, normaler Klick-Handler, Near/Far, bereits global bearbeiteter
|
||||
ACK, Ablehnung, Timeout, falsche Zielkarte/-position, native Zielauflösung und
|
||||
ACK-Sequenzzähler. Sie ersetzen keine Live-Fahrt oder visuelle Ingame-Abnahme.
|
||||
|
||||
Live-Ergebnisse des ersten Tests werden in
|
||||
[interactive-portals.md](../doc/clientless/interactive-portals.md) festgehalten.
|
||||
@@ -0,0 +1,371 @@
|
||||
# Fahrzeug-Haltestellen und Reiseverbindungen aufnehmen
|
||||
|
||||
Anleitung für den Vcore-Testserver, Stand 26.10.2026. Für Schiffe, Zeppeline,
|
||||
Tiefenbahn und unterstützte Fahrstühle. Gemeint sind native Transportobjekte,
|
||||
keine NPC-Fahrzeuge, Reittiere oder Flugtaxen.
|
||||
|
||||
Die Aufnahme erfolgt mit einem eingeloggten GM im Spiel. Alle Beispiele mit
|
||||
`.clientless` gehören in den Spielchat. Diagnose- und Testbefehle sind teilweise
|
||||
auch in der Worldserver-Konsole verfügbar; dort den führenden Punkt weglassen.
|
||||
Eine neue Verbindung benötigt keinen Build und normalerweise keinen Neustart.
|
||||
|
||||
## Grundprinzip
|
||||
|
||||
Pro physischer Haltestelle gibt es **einen gemeinsamen Node für Ein- und
|
||||
Ausstieg**, auf festem Boden unmittelbar vor dem Übergang zum Fahrzeug.
|
||||
Auf dem Deck wird kein zusätzlicher TravelGraph-Node angelegt.
|
||||
|
||||
Für eine Verbindung A ↔ B werden aufgenommen:
|
||||
|
||||
1. Haltestellen-Node A und dessen kurzer Weg vom Land zum Deck-Stehplatz.
|
||||
2. Haltestellen-Node B und dessen kurzer Weg zum selben Deck-Stehplatz.
|
||||
3. Eine Fahrtkante zwischen A und B.
|
||||
4. Bei Bedarf separate Groundwege vom übrigen Reisenetz zu A und B.
|
||||
|
||||
Die eigentliche Fahrt wird nicht aufgezeichnet. Der Bot fährt als Passenger
|
||||
mit dem normalen Fahrzeug. Zum Aussteigen läuft er die jeweilige
|
||||
Land-Deck-Aufnahme rückwärts ab. Deshalb müssen diese Wege in beiden
|
||||
Laufrichtungen begehbar sein.
|
||||
|
||||
| Verkehrsmittel | Node- und Fahrtkantentyp |
|
||||
| --- | --- |
|
||||
| Schiff | `ship` |
|
||||
| Zeppelin | `zeppelin` |
|
||||
| Tiefenbahn | `tram` |
|
||||
| Fahrstuhl | `elevator` |
|
||||
|
||||
## 1. Fahrzeug und Halte bestimmen
|
||||
|
||||
Für Schiffe und Zeppeline lassen sich die konfigurierten nativen Dienste
|
||||
ortsunabhängig auflisten:
|
||||
|
||||
```text
|
||||
.clientless transport services
|
||||
.clientless transport services <spawnId>
|
||||
```
|
||||
|
||||
Die Detailausgabe nennt tatsächliche Halte, Karten und Haltezeiten. Historische
|
||||
Fahrzeugnamen können veraltete Zielorte enthalten. Die ausgegebene Dockposition
|
||||
ist der Fahrzeugursprung und **nicht** die Position für den Haltestellen-Node.
|
||||
`services` listet nicht sämtliche Fahrstühle oder Tiefenbahnwagen auf.
|
||||
|
||||
Vor Ort das gewünschte Fahrzeug untersuchen:
|
||||
|
||||
```text
|
||||
.clientless transport near 50
|
||||
.clientless transport inspect <spawnId>
|
||||
```
|
||||
|
||||
Entscheidend sind:
|
||||
|
||||
- `spawn`: das konkrete Fahrzeug bzw. der konkrete Wagen; diese ID für `record start` verwenden.
|
||||
- `entry`: Fahrzeugmodell/-vorlage; wird beispielsweise für `stand list` verwendet.
|
||||
- `stopped=1`: Fahrzeug steht laut serverseitiger Bewegung.
|
||||
- `remainingPause`: verbleibende Haltezeit in Millisekunden.
|
||||
|
||||
**Spawn-ID und Entry-ID nicht verwechseln.** Beide Haltestellen einer
|
||||
Verbindung müssen dasselbe konkrete Fahrzeug verwenden. Bei der Tiefenbahn
|
||||
also auch denselben Wagen. Bei einem nativen Kartenwechsel bleibt die
|
||||
Spawn-ID gleich.
|
||||
|
||||
Für reine Patrouillen ohne geeignete Halte keine reguläre Passagierverbindung
|
||||
anlegen. Vor Beginn mindestens einen Umlauf beobachten und beide Einstiege
|
||||
prüfen. Fahrstühle sind noch nicht als konkrete Verbindung live abgenommen;
|
||||
ihre Unterstützung muss je Objekt überprüft werden.
|
||||
|
||||
## 2. Haltestellen-Node A anlegen
|
||||
|
||||
Auf festem Boden direkt vor Rampe, Steg oder Plattform stehen. Der Node muss
|
||||
auch bei abwesendem Fahrzeug sicher erreichbar sein. Vorher vorhandene Nodes
|
||||
prüfen, damit keine doppelte Haltestelle entsteht:
|
||||
|
||||
```text
|
||||
.clientless node list_nearby 30
|
||||
```
|
||||
|
||||
Beispiel für eine **neue** Schiffsverbindung mit frei gewählten Namen:
|
||||
|
||||
```text
|
||||
.clientless node add ship_hafen_a ship
|
||||
.clientless transport node ship_hafen_a
|
||||
```
|
||||
|
||||
Die Beispielnamen `ship_hafen_a`, `ship_hafen_b` und `mein_schiff_deck` sind
|
||||
Platzhalter für deine neue Verbindung. Namen eindeutig wählen, ohne Leerzeichen,
|
||||
zum Beispiel mit Kleinbuchstaben, Ziffern und Unterstrichen. Für andere
|
||||
Verkehrsmittel Namen und Nodetyp entsprechend ändern.
|
||||
|
||||
## 3. An A den Weg Land → Deck aufnehmen
|
||||
|
||||
Auf eine frische Ankunft warten. Zu Fuß am Node stehen, noch nicht Passenger.
|
||||
Der Start erlaubt höchstens **2 Yards Abstand zum Node**. Das Fahrzeug muss
|
||||
auf derselben Karte und in derselben Phase geladen sein und stillstehen.
|
||||
|
||||
`<spawnId>` vor Ausführung durch die ermittelte Zahl ersetzen:
|
||||
|
||||
```text
|
||||
.clientless transport record start ship_hafen_a <spawnId>
|
||||
```
|
||||
|
||||
Jetzt normal an Bord gehen und einen sicheren, ebenen Deckplatz aufsuchen.
|
||||
Einen kurzen Weg ohne Sprünge, Flug, Teleport oder Hindernisse wählen. Dann
|
||||
noch während **desselben Halts** speichern:
|
||||
|
||||
```text
|
||||
.clientless transport record stop mein_schiff_deck
|
||||
```
|
||||
|
||||
`stop` speichert den Übergang und legt den benannten Deck-Stehplatz bei Bedarf
|
||||
an. Der Standname bezeichnet eine Position relativ zum Fahrzeugmodell.
|
||||
Diesen Platz an der Gegenhaltestelle wieder verwenden.
|
||||
|
||||
Optionale Befehle während der Aufnahme:
|
||||
|
||||
```text
|
||||
.clientless transport record info
|
||||
.clientless transport record mark
|
||||
.clientless transport record cancel
|
||||
```
|
||||
|
||||
`info` zeigt Punkte, Länge, Passenger-Status und Fehler. `mark` erzwingt einen
|
||||
zusätzlichen Punkt, beispielsweise an einer engen Kurve. Die automatische
|
||||
Aufnahme läuft alle 100 ms mit mindestens 0,75 Yards Punktabstand;
|
||||
der Übergang zum Passenger wird automatisch markiert.
|
||||
|
||||
Die Aufnahme wird ungültig, wenn das Fahrzeug abfährt, der Recorder das
|
||||
Fahrzeug nach dem Einstieg wieder verlässt oder wechselt, ein Positionssprung
|
||||
über 8 Yards entsteht oder der Übergang länger als 60 Yards wird.
|
||||
Fehler mit `info` ansehen, danach mit `cancel` verwerfen und beim nächsten
|
||||
vollständigen Halt neu beginnen. Ein Logout oder Serverneustart verliert
|
||||
ungespeicherte Aufnahmen; jede Haltestelle zuerst erfolgreich speichern.
|
||||
|
||||
## 4. An B denselben Deckplatz aufnehmen
|
||||
|
||||
Mit genau diesem Fahrzeug zur anderen Haltestelle fahren. Dort aussteigen
|
||||
und auf festem Boden unmittelbar vor dem Übergang Node B anlegen:
|
||||
|
||||
```text
|
||||
.clientless node add ship_hafen_b ship
|
||||
```
|
||||
|
||||
Beim nächsten geeigneten Halt von B wieder auf dasselbe Fahrzeug gehen:
|
||||
|
||||
```text
|
||||
.clientless transport record start ship_hafen_b <spawnId>
|
||||
```
|
||||
|
||||
Zum selben lokalen Deckplatz laufen und mit demselben Standnamen speichern:
|
||||
|
||||
```text
|
||||
.clientless transport record stop mein_schiff_deck
|
||||
.clientless transport stops
|
||||
```
|
||||
|
||||
Ist der Stand bereits gespeichert, muss man beim Speichern höchstens
|
||||
1,5 Yards davon entfernt stehen. Die beiden aufgezeichneten Deck-Endpunkte
|
||||
müssen außerdem innerhalb von 3 Yards liegen. Es wird kein unbekannter Weg
|
||||
quer über das Deck zwischen unterschiedlichen Standorten ergänzt.
|
||||
|
||||
Optional kann der Deckplatz schon vor der ersten Aufnahme gespeichert werden,
|
||||
indem man an Bord am gewünschten Platz steht:
|
||||
|
||||
```text
|
||||
.clientless transport stand add mein_schiff_deck
|
||||
.clientless transport stand list
|
||||
```
|
||||
|
||||
Das ist nicht erforderlich, wenn `record stop` den Stand bereits angelegt hat.
|
||||
Ein vorhandener Standname wird nicht überschrieben.
|
||||
|
||||
## 5. Fahrtkante erstellen
|
||||
|
||||
Nach beiden erfolgreich gespeicherten Aufnahmen:
|
||||
|
||||
```text
|
||||
.clientless transport link ship_hafen_a ship_hafen_b ship 100 both
|
||||
```
|
||||
|
||||
`both` erstellt eine bidirektionale Kante. Nur verwenden, wenn das Fahrzeug
|
||||
beide Richtungen tatsächlich bedient. Ohne `both` gilt ausschließlich A → B:
|
||||
|
||||
```text
|
||||
.clientless transport link ship_hafen_a ship_hafen_b ship 100
|
||||
```
|
||||
|
||||
`100` ist ein positiver **Graphkostenwert**, keine Fahrzeit in Sekunden.
|
||||
Er eignet sich als vorläufiger Wert für einen isolierten Funktionstest.
|
||||
Bei konkurrierenden Fernrouten die Kosten später passend abstimmen.
|
||||
|
||||
Der Link prüft kompatible Aufnahmen, dieselbe Spawn-ID und denselben Entry,
|
||||
unterschiedliche Halte und passende Deck-Endpunkte. `tram`/`elevator` erwarten
|
||||
GameObject-Typ 11, `ship`/`zeppelin` Typ 15. Eine bereits gleichgerichtet
|
||||
vorhandene Kante wird nicht überschrieben. Erfolgreiches Speichern und
|
||||
Verknüpfen laden die betreffenden Daten automatisch neu.
|
||||
|
||||
## 6. Groundanschlüsse getrennt aufnehmen
|
||||
|
||||
Der Transportlink verbindet die beiden Haltestellen miteinander, nicht
|
||||
zugleich mit dem übrigen Reisenetz. Für einen fehlenden Landanschluss beide
|
||||
Nodes vorher anlegen bzw. einen bestehenden Stadtnode verwenden.
|
||||
|
||||
Beispiel: am bestehenden Node `stadt_knoten_a` anfangen und bis zur
|
||||
Haltestelle laufen:
|
||||
|
||||
```text
|
||||
.clientless route start stadt_knoten_a ship_hafen_a
|
||||
```
|
||||
|
||||
Am Haltestellen-Node auf festem Boden stoppen:
|
||||
|
||||
```text
|
||||
.clientless route stop
|
||||
```
|
||||
|
||||
Der Groundrecorder speichert bidirektional und lädt den Graph neu. Möglichst
|
||||
exakt an den Nodes beginnen und enden, damit die angehängten Endstücke keine
|
||||
Hindernisse schneiden. Diese Aufnahme endet **vor** dem Einstieg ins Fahrzeug;
|
||||
Land-Deck-Übergang und Fahrt werden separat wie oben erfasst. Auf Seite B
|
||||
entsprechend verfahren. Vollständig geprüfte MMAP-Anschlüsse können eine
|
||||
Groundaufnahme ersetzen, müssen aber für die konkrete Route funktionieren.
|
||||
|
||||
## 7. Botfahrt prüfen
|
||||
|
||||
Mit einem online befindlichen, untätigen Clientless-Bot an A testen.
|
||||
Die Zahl `26` ist Arthorans Charakter-GUID und bei einem anderen Bot zu ersetzen.
|
||||
Sein Login-Lua darf nicht gleichzeitig andere Reisen starten.
|
||||
|
||||
```text
|
||||
.clientless transport travel 26 ship_hafen_b
|
||||
.clientless transport status 26
|
||||
.clientless actioninfo 26
|
||||
```
|
||||
|
||||
`travel` stellt einen normalen Reiseauftrag in die Aktionswarteschlange;
|
||||
es unterbricht keine laufende Aktion. Nach Abschluss die Gegenrichtung testen:
|
||||
|
||||
```text
|
||||
.clientless transport travel 26 ship_hafen_a
|
||||
```
|
||||
|
||||
Eine funktionierende Fahrt durchläuft `Waiting` → `Boarding` → `Riding` →
|
||||
`Leaving` → `Arrived`. Prüfen, dass der Bot wirklich Passenger wird, einen
|
||||
gegebenenfalls nötigen Kartenwechsel abschließt, am Ziel aussteigt und den
|
||||
Landnode erreicht. Anschließend auch eine Reise mit Groundanschluss testen.
|
||||
Ein gespeicherter Link allein bestätigt noch keine funktionierende Botfahrt.
|
||||
|
||||
Abbruch einer bereits laufenden Transportfahrt:
|
||||
|
||||
```text
|
||||
.clientless transport cancel 26
|
||||
```
|
||||
|
||||
An Bord bleibt der Bot zunächst Passenger und versucht einen sicheren Ausstieg
|
||||
an einer aufgezeichneten Haltestelle. Das ist kein sofortiges Abspringen und
|
||||
kein allgemeines Leeren der Aktionswarteschlange.
|
||||
|
||||
## Aufnahmen korrigieren
|
||||
|
||||
**Nur die gespeicherte Land-Deck-Aufnahme ersetzen:**
|
||||
|
||||
```text
|
||||
.clientless transport forget ship_hafen_a
|
||||
```
|
||||
|
||||
Danach an A erneut `record start` und `record stop` ausführen. `forget`
|
||||
entfernt nur diese Übergangsaufnahme. Node, Groundkanten, Fahrtkante und
|
||||
benannte Deck-Stehplätze bleiben bestehen. Unvollständige Transportverbindungen
|
||||
werden währenddessen nicht geplant. Bleiben die Endpunkte kompatibel,
|
||||
muss die bestehende Fahrtkante nicht erneut angelegt werden.
|
||||
|
||||
**Haltestellen-Node verschieben:** am neuen festen Boden stehen und ausführen:
|
||||
|
||||
```text
|
||||
.clientless node move ship_hafen_a
|
||||
```
|
||||
|
||||
Danach die betroffene Transportaufnahme mit `forget` und neuer Aufnahme
|
||||
ersetzen. Angeschlossene Groundwege ebenfalls prüfen und gegebenenfalls neu
|
||||
aufnehmen; sie passen sich nicht automatisch an die neue Position an.
|
||||
|
||||
**Falsche Fahrtkante entfernen:**
|
||||
|
||||
```text
|
||||
.clientless route remove ship_hafen_a ship_hafen_b
|
||||
```
|
||||
|
||||
Dieser Befehl entfernt **alle Kanten zwischen diesen beiden Nodes in beiden
|
||||
Richtungen**, unabhängig vom Kantentyp. Daher nur verwenden, wenn genau diese
|
||||
Verbindungen entfernt werden sollen. Haltestellen-Aufnahmen und Nodes bleiben
|
||||
erhalten. Anschließend die gewünschte Fahrtkante mit `transport link` neu anlegen.
|
||||
|
||||
**Deckplätze prüfen oder einen unbenutzten Stand entfernen:**
|
||||
|
||||
```text
|
||||
.clientless transport stand list <entryId>
|
||||
.clientless transport stand remove <entryId> <standName>
|
||||
```
|
||||
|
||||
Ein von einer Haltestellen-Aufnahme verwendeter Stand kann nicht entfernt
|
||||
werden. Eine Änderung des gemeinsamen Deckplatzes erfordert passende neue
|
||||
Aufnahmen an beiden Haltestellen.
|
||||
|
||||
## Diagnose und häufige Fehler
|
||||
|
||||
| Meldung oder Symptom | Nächster Schritt |
|
||||
| --- | --- |
|
||||
| Abstand zum Node zu groß | Auf festem Boden bis auf höchstens 2 Yards an den Node zurückgehen. |
|
||||
| Recorder bereits Passenger | Aussteigen; Aufnahme muss auf festem Boden beginnen. |
|
||||
| Spawn nicht geladen | Spawn-ID, Karte und Phase prüfen; Fahrzeug vor Ort aufsuchen. |
|
||||
| Fahrzeug bewegt sich / Aufnahme nach Abfahrt ungültig | `record cancel`, nächsten vollständigen Halt abwarten. |
|
||||
| Dock bereits aufgenommen | Bestehende Daten mit `stops` prüfen; nur für einen beabsichtigten Ersatz `forget` verwenden. |
|
||||
| Standname an anderer Deckposition vorhanden | Zum ursprünglichen Stand gehen; alternativ neuen Namen und kompatible Aufnahmen verwenden. |
|
||||
| Link abgelehnt | Zwei gespeicherte Halte, gleicher Spawn/Entry, kompatible Deck-Endpunkte und richtigen Kantentyp prüfen. |
|
||||
| Bot wartet dauerhaft | Richtigen Spawn, tatsächliche Halte, Dockposition und `status` prüfen. |
|
||||
| Fahrt klappt, Weiterreise nicht | Groundanschluss der Haltestelle separat prüfen bzw. aufnehmen. |
|
||||
|
||||
Weitere Befehle:
|
||||
|
||||
```text
|
||||
.clientless transport stops
|
||||
.clientless transport node ship_hafen_a
|
||||
.clientless transport reload
|
||||
```
|
||||
|
||||
`reload` lädt Transportdaten und TravelGraph neu. Laufende Fahrten behalten
|
||||
jedoch ihre beim Start erfassten Definitionen. Änderungen deshalb mit einem
|
||||
neuen Reiseauftrag testen.
|
||||
|
||||
Konsolenbeispiele ohne Punkt; für räumliche Diagnose ist ein online
|
||||
befindlicher Bot als Beobachter auf der betreffenden Karte nötig:
|
||||
|
||||
```text
|
||||
clientless transport services
|
||||
clientless transport services <spawnId>
|
||||
clientless transport node ship_hafen_a
|
||||
clientless transport inspect <spawnId> 26
|
||||
clientless transport near 50 26
|
||||
clientless transport stops
|
||||
clientless transport status 26
|
||||
```
|
||||
|
||||
`remainingPause=4294967295` bezeichnet einen Halt ohne bekannte zeitliche
|
||||
Begrenzung. Zustandswechsel und Aufnahmefehler stehen in
|
||||
`adventurebot-navigation.log` im Arbeitsverzeichnis des Test-worldservers.
|
||||
|
||||
## Bestehende Beispiele
|
||||
|
||||
Diese Namen und IDs gehören bereits erfassten Verbindungen; nicht als neue
|
||||
Haltestellen doppelt anlegen oder zum Üben überschreiben:
|
||||
|
||||
| Verbindung | Haltestellen | Fahrzeug |
|
||||
| --- | --- | --- |
|
||||
| Sturmwind ↔ Rut’theran, The Bravery | `ship_sw_br` ↔ `ship_ruttheran_br` | Spawn 2, Entry 176310, Stand `bravery_center` |
|
||||
| Tiefenbahn | `tram_sw` ↔ `tram_if` | Im dokumentierten Fahrtest Wagen-Spawn 18803; bestehende Aufnahme mit `stops` prüfen. |
|
||||
|
||||
Bei der Tiefenbahn gibt es mehrere Wagen mit verschiedenen Spawn-IDs; deren
|
||||
Aufnahmen nicht mischen. Die kurzen Haltefenster erfordern einen kurzen
|
||||
Einstiegsweg und zügiges Speichern.
|
||||
|
||||
Weiterführend: [technische Transportdokumentation](../doc/clientless/transport-tools.md),
|
||||
[Schiff Sturmwind–Rut’theran](../doc/clientless/ship-stormwind-ruttheran.md),
|
||||
[Transport-Grid-Lifecycle](../doc/clientless/transport-grid-lifecycle.md).
|
||||
@@ -1 +1,5 @@
|
||||
*.sql
|
||||
!2026_10_25_00_clientless_transport.sql
|
||||
!2026_10_26_00_clientless_disable_taxi_585.sql
|
||||
!2026_10_26_01_clientless_stormwind_mage_tower.sql
|
||||
!2026_10_26_02_clientless_portal_use.sql
|
||||
@@ -0,0 +1,27 @@
|
||||
-- Vcore CPC: one shared embark/disembark node per physical stop.
|
||||
-- Apply explicitly to the dedicated test world database. No routes are seeded.
|
||||
CREATE TABLE IF NOT EXISTS vcore_clientless_transport_stop (
|
||||
node_key varchar(100) NOT NULL,
|
||||
transport_entry int unsigned NOT NULL,
|
||||
transport_spawn int unsigned NOT NULL,
|
||||
map int unsigned NOT NULL,
|
||||
dock_x float NOT NULL, dock_y float NOT NULL, dock_z float NOT NULL, dock_o float NOT NULL,
|
||||
period_ms int unsigned NOT NULL,
|
||||
stand_name varchar(100) NOT NULL,
|
||||
PRIMARY KEY (node_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vcore_clientless_transport_point (
|
||||
node_key varchar(100) NOT NULL,
|
||||
point_index int unsigned NOT NULL,
|
||||
on_transport tinyint unsigned NOT NULL,
|
||||
x float NOT NULL, y float NOT NULL, z float NOT NULL, o float NOT NULL,
|
||||
PRIMARY KEY (node_key,point_index)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vcore_clientless_transport_stand (
|
||||
transport_entry int unsigned NOT NULL,
|
||||
name varchar(100) NOT NULL,
|
||||
x float NOT NULL, y float NOT NULL, z float NOT NULL, o float NOT NULL,
|
||||
PRIMARY KEY (transport_entry,name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Explicit test-TravelGraph correction. No changes to WoW taxi definitions/NPCs.
|
||||
START TRANSACTION;
|
||||
UPDATE vcore_clientless_travel_node
|
||||
SET enabled=0,comment='Disabled: taxi 585 is not a regular player flight point (GM verified in Ruttheran)'
|
||||
WHERE node_key='taxi_585';
|
||||
UPDATE vcore_clientless_world_point
|
||||
SET enabled=0,comment='Disabled: taxi 585 is not a regular player flight point (GM verified in Ruttheran)'
|
||||
WHERE point_key='taxi_585';
|
||||
UPDATE vcore_clientless_travel_edge SET enabled=0
|
||||
WHERE from_node_key='taxi_585' OR to_node_key='taxi_585';
|
||||
COMMIT;
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Explicit local test-graph calibration; first run these worldserver commands:
|
||||
-- clientless portal import 702
|
||||
-- clientless portal import 704
|
||||
-- The importer seeds source nodes at the native AreaTrigger box centers.
|
||||
-- These centers are above the walkable floor. Keep their X/Y and move only
|
||||
-- the graph approach Z inside the native box, using the opposite arrival Z.
|
||||
-- Native areatrigger_teleport and DBC data are deliberately untouched.
|
||||
-- Reapply after reimporting 702/704, then reload the travel graph.
|
||||
START TRANSACTION;
|
||||
UPDATE vcore_clientless_world_point
|
||||
SET position_z=29.6207,
|
||||
comment='Mage tower 702 walkable approach inside native trigger; calibrated 2026-10-26'
|
||||
WHERE point_key='portal_at_702_source' AND map=0;
|
||||
UPDATE vcore_clientless_world_point
|
||||
SET position_z=148.616,
|
||||
comment='Mage tower 704 walkable approach inside native trigger; calibrated 2026-10-26'
|
||||
WHERE point_key='portal_at_704_source' AND map=0;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Apply explicitly to the dedicated test world DB. No portals are auto-enabled.
|
||||
-- A directed portal_use edge binds to one static clickable GameObject spawn.
|
||||
-- Entry and spell identify stale bindings after native portal data changes.
|
||||
CREATE TABLE IF NOT EXISTS vcore_clientless_portal_use (
|
||||
edge_id int unsigned NOT NULL,
|
||||
gameobject_spawn_id int unsigned NOT NULL,
|
||||
gameobject_entry int unsigned NOT NULL,
|
||||
spell_id int unsigned NOT NULL,
|
||||
PRIMARY KEY (edge_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -198,6 +198,9 @@ void ChatHandler::SendSysMessage(const char *str, bool escapeCharacters)
|
||||
|
||||
while (char* line = LineFromMessage(pos))
|
||||
{
|
||||
if (_logClientlessCommand)
|
||||
TC_LOG_INFO("server.adventurebot.navigation",
|
||||
"CLIENTLESS_COMMAND_REPLY account=%u text='%s'", m_session->GetAccountId(), line);
|
||||
BuildChatPacket(data, CHAT_MSG_SYSTEM, LANG_UNIVERSAL, nullptr, nullptr, line);
|
||||
m_session->SendPacket(&data);
|
||||
}
|
||||
@@ -411,6 +414,19 @@ bool ChatHandler::SetDataForCommandInTable(std::vector<ChatCommand>& table, char
|
||||
|
||||
bool ChatHandler::_ParseCommands(char const* text)
|
||||
{
|
||||
// Scope reply mirroring to this command invocation, including syntax errors.
|
||||
std::string const command(text);
|
||||
std::string const token = command.substr(0, command.find(' '));
|
||||
_logClientlessCommand = m_session && !token.empty() &&
|
||||
hasStringAbbr("clientless", token.c_str());
|
||||
struct ResetClientlessLog
|
||||
{
|
||||
bool& Enabled;
|
||||
~ResetClientlessLog() { Enabled = false; }
|
||||
} reset{ _logClientlessCommand };
|
||||
if (_logClientlessCommand)
|
||||
TC_LOG_INFO("server.adventurebot.navigation",
|
||||
"CLIENTLESS_COMMAND account=%u text='%s'", m_session->GetAccountId(), text);
|
||||
if (ExecuteCommandInTable(getCommandTable(), text, text))
|
||||
return true;
|
||||
|
||||
|
||||
@@ -147,6 +147,7 @@ class TC_GAME_API ChatHandler
|
||||
|
||||
// common global flag
|
||||
bool sentErrorMessage;
|
||||
bool _logClientlessCommand = false;
|
||||
};
|
||||
|
||||
class TC_GAME_API CliHandler : public ChatHandler
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
#include <G3D/Box.h>
|
||||
#include <G3D/CoordinateFrame.h>
|
||||
#include <G3D/Quat.h>
|
||||
#include <limits>
|
||||
|
||||
bool QuaternionData::isUnit() const
|
||||
{
|
||||
@@ -330,6 +331,54 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
bool GetTransportDockState(TransportDockState& state) const override
|
||||
{
|
||||
state = {};
|
||||
if (!_animationInfo || !_animationInfo->TotalTime)
|
||||
return false;
|
||||
state.Progress = _pathProgress;
|
||||
state.Period = _animationInfo->TotalTime;
|
||||
if (!_stopFrames.empty())
|
||||
{
|
||||
if (int32(GameTime::GetGameTimeMS() - _owner.GetUInt32Value(GAMEOBJECT_LEVEL)) >= 0)
|
||||
{
|
||||
state.Stopped = true;
|
||||
state.RemainingPause = std::numeric_limits<uint32>::max();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
auto current = _animationInfo->Path.upper_bound(_pathProgress);
|
||||
if (current == _animationInfo->Path.begin())
|
||||
return true;
|
||||
--current;
|
||||
auto next = std::next(current);
|
||||
// Consecutive equal animation positions represent an actual dwell,
|
||||
// unlike a GO state which may already denote a future destination.
|
||||
auto samePosition = [](TransportAnimationEntry const* a, TransportAnimationEntry const* b)
|
||||
{
|
||||
return std::fabs(a->Pos.X - b->Pos.X) < 0.001f &&
|
||||
std::fabs(a->Pos.Y - b->Pos.Y) < 0.001f && std::fabs(a->Pos.Z - b->Pos.Z) < 0.001f;
|
||||
};
|
||||
while (next != _animationInfo->Path.end() && samePosition(current->second, next->second))
|
||||
++next;
|
||||
uint32 end = next == std::next(current) ? _pathProgress :
|
||||
std::prev(next)->first;
|
||||
// A stationary origin with a rotating deck is not a boarding stop.
|
||||
auto rotation = _animationInfo->Rotations.upper_bound(_pathProgress);
|
||||
if (rotation != _animationInfo->Rotations.begin() && rotation != _animationInfo->Rotations.end())
|
||||
{
|
||||
auto before = std::prev(rotation)->second;
|
||||
auto after = rotation->second;
|
||||
if (std::fabs(before->X - after->X) + std::fabs(before->Y - after->Y) +
|
||||
std::fabs(before->Z - after->Z) + std::fabs(before->W - after->W) > 0.001f)
|
||||
return true;
|
||||
end = std::min(end, rotation->first);
|
||||
}
|
||||
state.Stopped = end > _pathProgress;
|
||||
state.RemainingPause = state.Stopped ? end - _pathProgress : 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
uint32 GetTransportPeriod() const
|
||||
{
|
||||
if (_animationInfo)
|
||||
@@ -2963,6 +3012,14 @@ void GameObject::BuildValuesUpdate(uint8 updateType, ByteBuffer* data, Player* t
|
||||
data->append(fieldBuffer);
|
||||
}
|
||||
|
||||
bool GameObject::GetTransportDockState(TransportDockState& state) const
|
||||
{
|
||||
state = {};
|
||||
if (Transport const* transport = ToTransport())
|
||||
return transport->GetDockState(state);
|
||||
return m_goTypeImpl && m_goTypeImpl->GetTransportDockState(state);
|
||||
}
|
||||
|
||||
std::vector<uint32> const* GameObject::GetPauseTimes() const
|
||||
{
|
||||
if (GameObjectType::Transport const* transport = dynamic_cast<GameObjectType::Transport const*>(m_goTypeImpl.get()))
|
||||
|
||||
@@ -33,6 +33,13 @@ class OPvPCapturePoint;
|
||||
class SpellInfo;
|
||||
class Transport;
|
||||
class TransportBase;
|
||||
struct TransportDockState
|
||||
{
|
||||
uint32 Progress = 0;
|
||||
uint32 Period = 0;
|
||||
uint32 RemainingPause = 0;
|
||||
bool Stopped = false;
|
||||
};
|
||||
class Unit;
|
||||
struct TransportAnimation;
|
||||
enum SpellTargetCheckTypes : uint8;
|
||||
@@ -55,6 +62,7 @@ public:
|
||||
virtual void Update([[maybe_unused]] uint32 diff) { }
|
||||
virtual void OnStateChanged([[maybe_unused]] GOState oldState, [[maybe_unused]] GOState newState) { }
|
||||
virtual void OnRelocated() { }
|
||||
virtual bool GetTransportDockState(TransportDockState&) const { return false; }
|
||||
|
||||
protected:
|
||||
GameObject& _owner;
|
||||
@@ -206,6 +214,7 @@ class TC_GAME_API GameObject : public WorldObject, public GridObject<GameObject>
|
||||
static void SetGoArtKit(uint8 artkit, GameObject* go, ObjectGuid::LowType lowguid = 0);
|
||||
|
||||
std::vector<uint32> const* GetPauseTimes() const;
|
||||
bool GetTransportDockState(TransportDockState& state) const;
|
||||
void SetPathProgressForClient(float progress);
|
||||
|
||||
void PlayAnimKit(int32 animKit);
|
||||
|
||||
@@ -273,6 +273,29 @@ void Transport::Update(uint32 diff)
|
||||
}
|
||||
}
|
||||
|
||||
bool Transport::GetDockState(TransportDockState& state) const
|
||||
{
|
||||
state = {};
|
||||
if (!_transportInfo || !GetTransportPeriod())
|
||||
return false;
|
||||
state.Period = GetTransportPeriod();
|
||||
state.Progress = _pathProgress % state.Period;
|
||||
TransportMovementState movement;
|
||||
size_t index;
|
||||
if (!_transportInfo->ComputePosition(state.Progress, &movement, &index))
|
||||
return false;
|
||||
if (movement == TransportMovementState::WaitingOnPauseWaypoint)
|
||||
for (TransportPathSegment const& segment : _transportInfo->PathLegs[index].Segments)
|
||||
if (state.Progress >= segment.SegmentEndArrivalTimestamp &&
|
||||
state.Progress < segment.SegmentEndArrivalTimestamp + segment.Delay)
|
||||
{
|
||||
state.Stopped = true;
|
||||
state.RemainingPause = segment.SegmentEndArrivalTimestamp + segment.Delay - state.Progress;
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void Transport::AddPassenger(WorldObject* passenger)
|
||||
{
|
||||
if (!IsInWorld())
|
||||
|
||||
@@ -90,6 +90,7 @@ class TC_GAME_API Transport : public GameObject, public TransportBase
|
||||
uint32 GetTransportPeriod() const { return GetUInt32Value(GAMEOBJECT_LEVEL); }
|
||||
void SetPeriod(uint32 period) { SetUInt32Value(GAMEOBJECT_LEVEL, period); }
|
||||
uint32 GetTimer() const { return _pathProgress; }
|
||||
bool GetDockState(TransportDockState& state) const;
|
||||
|
||||
void UpdatePosition(float x, float y, float z, float o);
|
||||
|
||||
|
||||
@@ -2383,6 +2383,7 @@ void ObjectMgr::RemoveCreatureFromGrid(ObjectGuid::LowType guid, CreatureData co
|
||||
void ObjectMgr::LoadGameObjects()
|
||||
{
|
||||
uint32 oldMSTime = getMSTime();
|
||||
_transportGameObjectSpawns.clear();
|
||||
|
||||
// 0 1 2 3 4 5 6
|
||||
QueryResult result = WorldDatabase.Query("SELECT gameobject.guid, id, map, position_x, position_y, position_z, orientation, "
|
||||
@@ -2595,7 +2596,13 @@ void ObjectMgr::LoadGameObjects()
|
||||
}
|
||||
|
||||
if (gameEvent == 0) // if not this is to be managed by GameEvent System or Pool system
|
||||
{
|
||||
AddGameobjectToGrid(guid, &data);
|
||||
// These objects become active only after their spawn grid is loaded.
|
||||
// Index them so a distant station cannot leave half a service absent.
|
||||
if (gInfo->type == GAMEOBJECT_TYPE_TRANSPORT)
|
||||
_transportGameObjectSpawns[data.mapId].push_back(guid);
|
||||
}
|
||||
}
|
||||
while (result->NextRow());
|
||||
|
||||
|
||||
@@ -1392,6 +1392,11 @@ class TC_GAME_API ObjectMgr
|
||||
return &itr->second;
|
||||
}
|
||||
GameObjectDataContainer const& GetAllGameObjectData() const { return _gameObjectDataStore; }
|
||||
std::vector<ObjectGuid::LowType> const* GetTransportGameObjectSpawns(uint32 mapId) const
|
||||
{
|
||||
auto itr = _transportGameObjectSpawns.find(mapId);
|
||||
return itr != _transportGameObjectSpawns.end() ? &itr->second : nullptr;
|
||||
}
|
||||
GameObjectData const* GetGameObjectData(ObjectGuid::LowType spawnId) const
|
||||
{
|
||||
GameObjectDataContainer::const_iterator itr = _gameObjectDataStore.find(spawnId);
|
||||
@@ -1752,6 +1757,7 @@ class TC_GAME_API ObjectMgr
|
||||
LinkedRespawnContainer _linkedRespawnStore;
|
||||
CreatureLocaleContainer _creatureLocaleStore;
|
||||
GameObjectDataContainer _gameObjectDataStore;
|
||||
std::unordered_map<uint32, std::vector<ObjectGuid::LowType>> _transportGameObjectSpawns;
|
||||
GameObjectLocaleContainer _gameObjectLocaleStore;
|
||||
GameObjectTemplateContainer _gameObjectTemplateStore;
|
||||
GameObjectTemplateAddonContainer _gameObjectTemplateAddonStore;
|
||||
|
||||
@@ -244,6 +244,24 @@ void WorldSession::HandleMoveTeleportAck(WorldPackets::Movement::MoveTeleportAck
|
||||
return;
|
||||
|
||||
|
||||
CompleteNearTeleport(plMover);
|
||||
}
|
||||
|
||||
void WorldSession::CompleteClientlessTeleport()
|
||||
{
|
||||
if (!IsClientless() || !GetPlayer())
|
||||
return;
|
||||
bool const pending = GetPlayer()->IsBeingTeleported();
|
||||
if (GetPlayer()->IsBeingTeleportedFar())
|
||||
HandleMoveWorldportAck();
|
||||
else if (GetPlayer()->IsBeingTeleportedNear())
|
||||
CompleteNearTeleport(GetPlayer());
|
||||
if (pending && GetPlayer() && GetPlayer()->IsInWorld() && !GetPlayer()->IsBeingTeleported())
|
||||
++_clientlessTeleportSequence;
|
||||
}
|
||||
|
||||
void WorldSession::CompleteNearTeleport(Player* plMover)
|
||||
{
|
||||
plMover->SetSemaphoreTeleportNear(false);
|
||||
|
||||
uint32 old_zone = plMover->GetZoneId();
|
||||
|
||||
@@ -116,6 +116,27 @@ void Map::LoadAllCells()
|
||||
LoadGrid((cellX + 0.5f - CENTER_GRID_CELL_ID) * SIZE_OF_GRID_CELL, (cellY + 0.5f - CENTER_GRID_CELL_ID) * SIZE_OF_GRID_CELL);
|
||||
}
|
||||
|
||||
void Map::LoadTransportGameObjectGrids()
|
||||
{
|
||||
auto spawns = sObjectMgr->GetTransportGameObjectSpawns(GetId());
|
||||
if (!spawns)
|
||||
return;
|
||||
|
||||
uint32 grids = 0;
|
||||
for (ObjectGuid::LowType spawnId : *spawns)
|
||||
{
|
||||
GameObjectData const* data = sObjectMgr->GetGameObjectData(spawnId);
|
||||
if (!data || data->mapId != GetId() || !(data->spawnMask & (1u << GetSpawnMode())))
|
||||
continue;
|
||||
// Use the normal loader: respawns, pools, phases and spawn groups retain
|
||||
// their existing rules. Loading an already loaded grid is idempotent.
|
||||
if (EnsureGridLoaded(Cell(data->spawnPoint.GetPositionX(), data->spawnPoint.GetPositionY())))
|
||||
++grids;
|
||||
}
|
||||
TC_LOG_INFO("server.loading", "Initialized transport GameObject grids: map=%u instance=%u grids=%u candidates=%u",
|
||||
GetId(), GetInstanceId(), grids, uint32(spawns->size()));
|
||||
}
|
||||
|
||||
void Map::InitStateMachine()
|
||||
{
|
||||
si_GridStates[GRID_STATE_INVALID] = new InvalidState();
|
||||
|
||||
@@ -218,6 +218,7 @@ class TC_GAME_API Map : public GridRefManager<NGridType>
|
||||
void SetUnloadLock(GridCoord const& p, bool on) { getNGrid(p.x_coord, p.y_coord)->setUnloadExplicitLock(on); }
|
||||
void LoadGrid(float x, float y);
|
||||
void LoadAllCells();
|
||||
void LoadTransportGameObjectGrids();
|
||||
bool UnloadGrid(NGridType& ngrid, bool pForce);
|
||||
void GridMarkNoUnload(uint32 x, uint32 y);
|
||||
void GridUnmarkNoUnload(uint32 x, uint32 y);
|
||||
|
||||
@@ -74,6 +74,8 @@ Map* MapManager::CreateWorldMap(uint32 mapId, uint32 instanceId)
|
||||
map->LoadRespawnTimes();
|
||||
map->LoadCorpseData();
|
||||
|
||||
map->LoadTransportGameObjectGrids();
|
||||
|
||||
if (sWorld->getBoolConfig(CONFIG_BASEMAP_LOAD_GRIDS))
|
||||
map->LoadAllCells();
|
||||
|
||||
@@ -104,6 +106,8 @@ InstanceMap* MapManager::CreateInstance(uint32 mapId, uint32 instanceId, Instanc
|
||||
bool load_data = save != nullptr;
|
||||
map->CreateInstanceData(load_data);
|
||||
|
||||
map->LoadTransportGameObjectGrids();
|
||||
|
||||
if (sWorld->getBoolConfig(CONFIG_INSTANCEMAP_LOAD_GRIDS))
|
||||
map->LoadAllCells();
|
||||
|
||||
@@ -118,6 +122,7 @@ BattlegroundMap* MapManager::CreateBattleground(uint32 mapId, uint32 instanceId,
|
||||
ASSERT(map->IsBattlegroundOrArena());
|
||||
map->SetBG(bg);
|
||||
bg->SetBgMap(map);
|
||||
map->LoadTransportGameObjectGrids();
|
||||
return map;
|
||||
}
|
||||
|
||||
|
||||
@@ -743,6 +743,8 @@ class TC_GAME_API WorldSession
|
||||
void HandleForceSpeedChangeAck(WorldPacket& recvData);
|
||||
void HandleMoveKnockBackAck(WorldPacket& recvPacket);
|
||||
void HandleMoveTeleportAck(WorldPackets::Movement::MoveTeleportAck& packet);
|
||||
void CompleteClientlessTeleport();
|
||||
uint64 GetClientlessTeleportSequence() const { return _clientlessTeleportSequence; }
|
||||
void HandleMoveWaterWalkAck(WorldPacket& recvPacket);
|
||||
void HandleFeatherFallAck(WorldPacket& recvData);
|
||||
void HandleMoveHoverAck(WorldPacket& recvData);
|
||||
@@ -1318,6 +1320,7 @@ class TC_GAME_API WorldSession
|
||||
void SendLoadCUFProfiles();
|
||||
|
||||
private:
|
||||
void CompleteNearTeleport(Player* playerMover);
|
||||
void ProcessQueryCallbacks();
|
||||
|
||||
QueryCallbackProcessor _queryProcessor;
|
||||
@@ -1380,6 +1383,7 @@ class TC_GAME_API WorldSession
|
||||
Player* _player;
|
||||
WorldSessionMode _sessionMode;
|
||||
ObjectGuid _clientlessPendingPlayerGuid;
|
||||
uint64 _clientlessTeleportSequence = 0;
|
||||
std::shared_ptr<WorldSocket> m_Socket[2];
|
||||
std::string m_Address; // Current Remote Address
|
||||
// std::string m_LAddress; // Last Attempted Remote Adress - we can not set attempted ip for a non-existing session!
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "ClientlessActionExecutorMgr.h"
|
||||
#include "ClientlessTransportMgr.h"
|
||||
|
||||
#include "ClientlessPerceptionMgr.h"
|
||||
#include "ClientlessPlayerMgr.h"
|
||||
@@ -13,6 +14,8 @@
|
||||
#include "Player.h"
|
||||
#include "MotionMaster.h"
|
||||
#include "PathGenerator.h" // VCORE_C819_DEFERRED_START_NODES_V2
|
||||
#include "GameObject.h"
|
||||
#include "Map.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include "Position.h"
|
||||
@@ -149,6 +152,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 +180,17 @@ ClientlessActionExecutionStatus const* ClientlessActionExecutorMgr::GetStatus(ui
|
||||
|
||||
bool ClientlessActionExecutorMgr::StartAction(Player* player, ClientlessAction const& action, Record& record)
|
||||
{
|
||||
record.TransportActive = false;
|
||||
record.PortalUseActive = 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 +740,7 @@ bool ClientlessActionExecutorMgr::StartTravelToLocation(
|
||||
std::string anchorNode;
|
||||
std::string error;
|
||||
float proxyDistance = std::numeric_limits<float>::infinity();
|
||||
sClientlessTravelGraphMgr->EnsureLoaded();
|
||||
if (!sClientlessTravelGraphMgr->FindNearestNodeToPosition(
|
||||
point->MapId, point->X, point->Y, point->Z, anchorNode, proxyDistance))
|
||||
{
|
||||
@@ -796,15 +815,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 +831,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 +893,25 @@ bool ClientlessActionExecutorMgr::StartRouteStep(
|
||||
step.NodeKey.c_str(), step.NodeType.c_str(), step.EdgeType.c_str(),
|
||||
step.EdgeDatabaseId, step.EdgeReverse ? 1u : 0u, record.RecoveryCount);
|
||||
|
||||
if (step.EdgeType == "portal_use")
|
||||
return StartPortalUse(player, characterGuid, record);
|
||||
|
||||
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 +1024,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 +1067,7 @@ bool ClientlessActionExecutorMgr::StartRouteStep(
|
||||
areaTriggerPacket << triggerId;
|
||||
player->GetSession()->HandleAreaTriggerOpcode(areaTriggerPacket);
|
||||
|
||||
if (player->IsBeingTeleportedFar())
|
||||
if (player->IsBeingTeleported())
|
||||
{
|
||||
if (!player->GetSession()->IsClientless())
|
||||
{
|
||||
@@ -1055,13 +1077,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 +1103,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;
|
||||
@@ -1408,6 +1440,116 @@ bool ClientlessActionExecutorMgr::StartRouteStep(
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ClientlessActionExecutorMgr::StartPortalUse(Player* player,
|
||||
uint32 characterGuid, Record& record)
|
||||
{
|
||||
auto const& step = record.Route[record.RouteStep];
|
||||
auto const* portal = sClientlessTravelGraphMgr->GetPortalUse(step.EdgeDatabaseId);
|
||||
auto fail = [&](char const* reason)
|
||||
{
|
||||
TC_LOG_WARN("server.adventurebot.navigation", "PORTAL_USE_FAILED guid=%u edge=%u reason='%s'",
|
||||
characterGuid, step.EdgeDatabaseId, reason);
|
||||
record.PortalUseActive = false;
|
||||
record.Active = false;
|
||||
record.Status.State = ClientlessActionExecutionState::Failed;
|
||||
record.Status.Detail = reason;
|
||||
return false;
|
||||
};
|
||||
if (!portal || !record.RouteStep || step.EdgeReverse ||
|
||||
player->GetMapId() != portal->Source.MapId || !player->IsAlive() ||
|
||||
player->IsBeingTeleported() || player->GetTransport() ||
|
||||
!player->GetSession() || !player->GetSession()->IsClientless())
|
||||
return fail("interactive portal binding or player state invalid");
|
||||
|
||||
GameObject* object = nullptr;
|
||||
auto const& objects = player->GetMap()->GetGameObjectBySpawnIdStore();
|
||||
auto range = objects.equal_range(portal->SpawnId);
|
||||
for (auto itr = range.first; itr != range.second; ++itr)
|
||||
{
|
||||
GameObject* candidate = itr->second;
|
||||
if (candidate && candidate->GetEntry() == portal->Entry && candidate->isSpawned() &&
|
||||
player->IsInPhase(candidate) && player->CanSeeOrDetect(candidate) &&
|
||||
!candidate->HasFlag(GAMEOBJECT_FLAGS, GO_FLAG_NOT_SELECTABLE | GO_FLAG_LOCKED) &&
|
||||
candidate->GetGoType() == GAMEOBJECT_TYPE_SPELLCASTER &&
|
||||
candidate->GetGOInfo()->spellcaster.spell == portal->SpellId &&
|
||||
player->GetGameObjectIfCanInteractWith(candidate->GetGUID()) == candidate)
|
||||
{
|
||||
object = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!object)
|
||||
return fail("interactive portal not spawned, visible or in interaction range");
|
||||
|
||||
sClientlessTravelMgr->CancelTravel(characterGuid);
|
||||
record.EdgePath.clear();
|
||||
player->StopMoving();
|
||||
sClientlessLuaEngine->DismountGroundMount(player, "interactive-portal-edge");
|
||||
record.PortalUseSpawnId = portal->SpawnId;
|
||||
record.PortalUseActive = true;
|
||||
record.PortalUseTeleportSequence = player->GetSession()->GetClientlessTeleportSequence();
|
||||
record.PortalUseElapsedMs = 0;
|
||||
record.Active = true;
|
||||
record.Status.State = ClientlessActionExecutionState::Moving;
|
||||
record.Status.TargetName = "node:" + step.NodeKey;
|
||||
record.Status.Detail = "waiting for interactive portal teleport";
|
||||
TC_LOG_INFO("server.adventurebot.navigation", "PORTAL_USE_START guid=%u edge=%u spawn=%u entry=%u spell=%u sourceMap=%u targetMap=%u",
|
||||
characterGuid, step.EdgeDatabaseId, portal->SpawnId, portal->Entry, portal->SpellId,
|
||||
portal->Source.MapId, step.MapId);
|
||||
// Same packet handler as a player's click. Native Use/AI, party checks,
|
||||
// spell cast restrictions and scripted conditions remain authoritative.
|
||||
WorldPacket usePacket(CMSG_GAMEOBJ_USE, 8);
|
||||
usePacket << object->GetGUID();
|
||||
player->GetSession()->HandleGameObjectUseOpcode(usePacket);
|
||||
return true;
|
||||
}
|
||||
|
||||
void ClientlessActionExecutorMgr::UpdatePortalUse(Player* player,
|
||||
uint32 characterGuid, Record& record)
|
||||
{
|
||||
auto const& step = record.Route[record.RouteStep];
|
||||
if (player->IsBeingTeleported())
|
||||
{
|
||||
WorldSession* session = player->GetSession();
|
||||
session->CompleteClientlessTeleport();
|
||||
player = session->GetPlayer();
|
||||
if (!player)
|
||||
{
|
||||
record.PortalUseActive = false;
|
||||
record.Active = false;
|
||||
record.Status.State = ClientlessActionExecutionState::Failed;
|
||||
record.Status.Detail = "interactive portal transfer lost its player";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
float const arrivalError = std::hypot(std::hypot(player->GetPositionX() - step.X,
|
||||
player->GetPositionY() - step.Y), player->GetPositionZ() - step.Z);
|
||||
bool const arrived = player->GetSession()->GetClientlessTeleportSequence() != record.PortalUseTeleportSequence && player->IsInWorld() &&
|
||||
!player->IsBeingTeleported() && player->GetMapId() == step.MapId && arrivalError <= 5.0f;
|
||||
if (arrived)
|
||||
{
|
||||
TC_LOG_INFO("server.adventurebot.navigation", "EDGE_COMPLETE guid=%u name='%s' edgeType='portal_use' edgeDbId=%u node='%s' spawn=%u map=%u arrivalError=%.2f recovery=%u",
|
||||
characterGuid, player->GetName().c_str(), step.EdgeDatabaseId, step.NodeKey.c_str(),
|
||||
record.PortalUseSpawnId, player->GetMapId(), arrivalError, record.RecoveryCount);
|
||||
record.PortalUseActive = false;
|
||||
++record.RouteStep;
|
||||
StartRouteStep(player, characterGuid, record);
|
||||
return;
|
||||
}
|
||||
// A denied click/cast is never success. Do not retry it every tick or
|
||||
// manufacture a teleport. Leave enough time for native cast/delayed events.
|
||||
if (record.PortalUseElapsedMs < 10000)
|
||||
return;
|
||||
TC_LOG_WARN("server.adventurebot.navigation", "PORTAL_USE_FAILED guid=%u edge=%u spawn=%u map=%u expectedMap=%u arrivalError=%.2f pending=%u reason='teleport-not-confirmed'",
|
||||
characterGuid, step.EdgeDatabaseId, record.PortalUseSpawnId, player->GetMapId(),
|
||||
step.MapId, arrivalError, player->IsBeingTeleported() ? 1u : 0u);
|
||||
record.PortalUseActive = false;
|
||||
record.Active = false;
|
||||
record.Status.State = ClientlessActionExecutionState::Failed;
|
||||
record.Status.Detail = "interactive portal teleport not confirmed within 10 seconds";
|
||||
}
|
||||
|
||||
bool ClientlessActionExecutorMgr::StartRecordedEdgePath(
|
||||
Player* player,
|
||||
uint32 characterGuid,
|
||||
@@ -1649,6 +1791,7 @@ bool ClientlessActionExecutorMgr::RecoverTravel(
|
||||
{
|
||||
std::string anchorNode;
|
||||
float proxyDistance = std::numeric_limits<float>::infinity();
|
||||
sClientlessTravelGraphMgr->EnsureLoaded();
|
||||
if (sClientlessTravelGraphMgr->FindNearestNodeToPosition(
|
||||
record.FinalMapId, record.FinalX, record.FinalY, record.FinalZ,
|
||||
anchorNode, proxyDistance))
|
||||
@@ -1738,6 +1881,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 +2238,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 +2256,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 +2276,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(
|
||||
@@ -2154,19 +2348,31 @@ void ClientlessActionExecutorMgr::UpdateActive(
|
||||
ClientlessTravelMgr::StateName(travel->State));
|
||||
}
|
||||
|
||||
void ClientlessActionExecutorMgr::Update(uint32 /*diff*/)
|
||||
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();
|
||||
if (!player || !player->IsInWorld())
|
||||
if (!player)
|
||||
continue;
|
||||
|
||||
Record& record = _records[characterGuid];
|
||||
|
||||
// Far teleport removes the player from the old map until the client
|
||||
// ACK. Service this state before the ordinary in-world update guard.
|
||||
if (record.Active && record.PortalUseActive)
|
||||
{
|
||||
record.PortalUseElapsedMs += std::min(diff, uint32(30000));
|
||||
UpdatePortalUse(player, characterGuid, record);
|
||||
continue;
|
||||
}
|
||||
if (!player->IsInWorld())
|
||||
continue;
|
||||
|
||||
if (record.Active)
|
||||
{
|
||||
UpdateActive(player, characterGuid, record);
|
||||
|
||||
@@ -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,11 @@ private:
|
||||
std::vector<ClientlessTravelEdgePoint> EdgePath;
|
||||
|
||||
// C8.10b: active server-side taxi edge.
|
||||
bool TransportActive = false;
|
||||
bool PortalUseActive = false;
|
||||
uint64 PortalUseTeleportSequence = 0;
|
||||
uint32 PortalUseElapsedMs = 0;
|
||||
uint32 PortalUseSpawnId = 0;
|
||||
bool TaxiActive = false;
|
||||
uint32 TaxiPathId = 0;
|
||||
uint32 TaxiSourceNodeId = 0;
|
||||
@@ -98,7 +104,10 @@ 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 StartPortalUse(Player* player, uint32 characterGuid, Record& record);
|
||||
void UpdatePortalUse(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);
|
||||
bool StartRecoveryWander(Player* player, uint32 characterGuid, Record& record, std::string const& reason);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
#include "ClientlessLuaEngine.h"
|
||||
#include "ClientlessPlayerMgr.h"
|
||||
#include "ClientlessActionExecutorMgr.h"
|
||||
#include "ClientlessTravelMgr.h"
|
||||
#include "Config.h"
|
||||
#include "Log.h"
|
||||
#include "Player.h"
|
||||
#include "Random.h"
|
||||
@@ -14,6 +17,15 @@
|
||||
|
||||
namespace
|
||||
{
|
||||
// World-thread only. Script effects are committed only after a successful reload.
|
||||
struct ReloadEffects
|
||||
{
|
||||
std::vector<ClientlessAction> Actions;
|
||||
ClientlessIntent Intent;
|
||||
bool IntentChanged = false;
|
||||
};
|
||||
ReloadEffects* pendingReload = nullptr;
|
||||
|
||||
uint32 BoundGuid(lua_State* L) { return uint32(lua_tointeger(L, lua_upvalueindex(1))); }
|
||||
|
||||
std::unordered_map<std::string, std::string> ReadMap(lua_State* L, int index)
|
||||
@@ -48,12 +60,30 @@ int LuaIntend(lua_State* L)
|
||||
ClientlessIntent intent;
|
||||
intent.Name = luaL_checkstring(L, 1);
|
||||
if (lua_gettop(L) >= 2) intent.Parameters = ReadMap(L, 2);
|
||||
if (pendingReload)
|
||||
{
|
||||
bool valid = !intent.Empty();
|
||||
if (valid)
|
||||
{
|
||||
pendingReload->Intent = std::move(intent);
|
||||
pendingReload->IntentChanged = true;
|
||||
}
|
||||
lua_pushboolean(L, valid);
|
||||
return 1;
|
||||
}
|
||||
lua_pushboolean(L, sClientlessPlayerMgr->SetIntent(BoundGuid(L), std::move(intent)));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int LuaClearIntent(lua_State* L)
|
||||
{
|
||||
if (pendingReload)
|
||||
{
|
||||
pendingReload->Intent = ClientlessIntent();
|
||||
pendingReload->IntentChanged = true;
|
||||
lua_pushboolean(L, true);
|
||||
return 1;
|
||||
}
|
||||
lua_pushboolean(L, sClientlessPlayerMgr->ClearIntent(BoundGuid(L)));
|
||||
return 1;
|
||||
}
|
||||
@@ -63,6 +93,13 @@ int LuaQueueAction(lua_State* L)
|
||||
ClientlessAction action;
|
||||
action.Name = luaL_checkstring(L, 1);
|
||||
if (lua_gettop(L) >= 2) action.Parameters = ReadMap(L, 2);
|
||||
if (pendingReload)
|
||||
{
|
||||
bool valid = !action.Name.empty();
|
||||
if (valid) pendingReload->Actions.push_back(std::move(action));
|
||||
lua_pushboolean(L, valid);
|
||||
return 1;
|
||||
}
|
||||
lua_pushboolean(L, sClientlessPlayerMgr->QueueAction(BoundGuid(L), std::move(action)));
|
||||
return 1;
|
||||
}
|
||||
@@ -184,7 +221,10 @@ ClientlessLuaEngine* ClientlessLuaEngine::instance()
|
||||
|
||||
ClientlessLuaEngine::ClientlessLuaEngine()
|
||||
{
|
||||
_scriptRoot = std::getenv("VCORE_CLIENTLESS_SCRIPT_DIR") ? std::getenv("VCORE_CLIENTLESS_SCRIPT_DIR") : "scripts/clientless";
|
||||
_scriptRoot = sConfigMgr->GetStringDefault("Clientless.ScriptRoot", "scripts/clientless");
|
||||
if (char const* overrideRoot = std::getenv("VCORE_CLIENTLESS_SCRIPT_DIR"))
|
||||
if (*overrideRoot) _scriptRoot = overrideRoot;
|
||||
_scriptRoot = std::filesystem::absolute(_scriptRoot).lexically_normal().string();
|
||||
_state = luaL_newstate();
|
||||
if (!_state)
|
||||
{
|
||||
@@ -445,6 +485,61 @@ bool ClientlessLuaEngine::ExecuteAdventureScript(Player* player)
|
||||
return ExecuteScript(player, (root / "adventure" / "generic.lua").string(), "generic adventure");
|
||||
}
|
||||
|
||||
bool ClientlessLuaEngine::ReloadAdventureScript(Player* player, std::string& detail)
|
||||
{
|
||||
if (!player || !player->IsInWorld() || !sClientlessPlayerMgr->IsManaged(player->GetGUID().GetCounter()))
|
||||
{
|
||||
detail = "Reload requires an online managed clientless player.";
|
||||
return false;
|
||||
}
|
||||
uint32 guid = player->GetGUID().GetCounter();
|
||||
auto action = sClientlessActionExecutorMgr->GetStatus(guid);
|
||||
auto travel = sClientlessTravelMgr->GetStatus(guid);
|
||||
if (pendingReload || sClientlessPlayerMgr->GetQueuedActionCount(guid) ||
|
||||
(action && (action->State == ClientlessActionExecutionState::SelectingTarget ||
|
||||
action->State == ClientlessActionExecutionState::Moving)) ||
|
||||
(travel && travel->State == ClientlessTravelState::Moving) ||
|
||||
player->GetTransport() || player->IsInFlight() || player->IsBeingTeleported())
|
||||
{
|
||||
detail = "Reload refused: bot has queued actions, is travelling or is aboard a transport. Wait until idle.";
|
||||
return false;
|
||||
}
|
||||
|
||||
auto previous = _profiles.find(guid);
|
||||
bool hadProfile = previous != _profiles.end();
|
||||
ClientlessAdventureProfile backup = hadProfile ? previous->second : ClientlessAdventureProfile();
|
||||
ReloadEffects effects;
|
||||
bool success;
|
||||
{
|
||||
struct Capture
|
||||
{
|
||||
explicit Capture(ReloadEffects& effects) { pendingReload = &effects; }
|
||||
~Capture() { pendingReload = nullptr; }
|
||||
} capture(effects);
|
||||
success = LoadAdventureProfile(player) && ExecuteAdventureScript(player);
|
||||
}
|
||||
if (!success)
|
||||
{
|
||||
if (hadProfile) _profiles[guid] = std::move(backup);
|
||||
else _profiles.erase(guid);
|
||||
detail = "Lua reload failed. Previous profile retained; no new actions or intent applied. See server log.";
|
||||
}
|
||||
else
|
||||
{
|
||||
if (effects.IntentChanged)
|
||||
{
|
||||
if (effects.Intent.Empty()) sClientlessPlayerMgr->ClearIntent(guid);
|
||||
else sClientlessPlayerMgr->SetIntent(guid, std::move(effects.Intent));
|
||||
}
|
||||
for (auto& queued : effects.Actions)
|
||||
sClientlessPlayerMgr->QueueAction(guid, std::move(queued));
|
||||
detail = "Profile and adventure script reloaded; queued actions=" + std::to_string(effects.Actions.size()) + ".";
|
||||
}
|
||||
TC_LOG_INFO("server.adventurebot.navigation", "LUA_RELOAD guid=%u success=%u root='%s' detail='%s'",
|
||||
guid, success ? 1u : 0u, _scriptRoot.c_str(), detail.c_str());
|
||||
return success;
|
||||
}
|
||||
|
||||
bool ClientlessLuaEngine::ExecuteTestScript(Player* player, std::string const& scriptName)
|
||||
{
|
||||
if (!player || scriptName.empty() || scriptName.find("..") != std::string::npos || scriptName.find('/') != std::string::npos || scriptName.find('\\') != std::string::npos)
|
||||
|
||||
@@ -46,6 +46,7 @@ public:
|
||||
|
||||
bool ExecuteAdventureScript(Player* player);
|
||||
bool ExecuteTestScript(Player* player, std::string const& scriptName);
|
||||
bool ReloadAdventureScript(Player* player, std::string& detail);
|
||||
|
||||
bool LoadAdventureProfile(Player* player);
|
||||
ClientlessAdventureProfile const* GetAdventureProfile(uint32 characterGuid) const;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "ClientlessPlayerMgr.h"
|
||||
#include "ClientlessTravelMgr.h"
|
||||
#include "ClientlessTransportMgr.h"
|
||||
#include "ClientlessPerceptionMgr.h"
|
||||
#include "ClientlessActionExecutorMgr.h"
|
||||
#include "Log.h"
|
||||
@@ -147,6 +148,7 @@ void ClientlessPlayerMgr::Update(uint32 diff)
|
||||
// CLIENTLESS_PLAYER_CORE_C8_3_LOCAL_PERCEPTION
|
||||
sClientlessPerceptionMgr->Update(diff);
|
||||
// CLIENTLESS_PLAYER_CORE_C8_4_ACTION_EXECUTOR
|
||||
sClientlessTransportMgr->Update(diff);
|
||||
sClientlessActionExecutorMgr->Update(diff);
|
||||
|
||||
for (auto itr = _records.begin(); itr != _records.end(); )
|
||||
@@ -157,6 +159,7 @@ void ClientlessPlayerMgr::Update(uint32 diff)
|
||||
|
||||
if (state == ClientlessPlayerState::Offline)
|
||||
{
|
||||
sClientlessActionExecutorMgr->Clear(characterGuid);
|
||||
itr = _records.erase(itr);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,798 @@
|
||||
#include "ClientlessTransportMgr.h"
|
||||
#include "Chat.h"
|
||||
#include "ClientlessPlayerMgr.h"
|
||||
#include "ClientlessRouteRecorderMgr.h"
|
||||
#include "ClientlessTravelGraphMgr.h"
|
||||
#include "ClientlessTravelMgr.h"
|
||||
#include "DatabaseEnv.h"
|
||||
#include "GameObject.h"
|
||||
#include "GameTime.h"
|
||||
#include "Opcodes.h"
|
||||
#include "WorldPacket.h"
|
||||
#include "Log.h"
|
||||
#include "Map.h"
|
||||
#include "MotionMaster.h"
|
||||
#include "ObjectAccessor.h"
|
||||
#include "ObjectMgr.h"
|
||||
#include "Player.h"
|
||||
#include "Transport.h"
|
||||
#include "TransportMgr.h"
|
||||
#include "World.h"
|
||||
#include "WorldSession.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
#include <sstream>
|
||||
|
||||
namespace
|
||||
{
|
||||
void PublishPassengerState(Player* player)
|
||||
{
|
||||
player->m_movementInfo.guid = player->GetGUID();
|
||||
player->m_movementInfo.pos.Relocate(player->GetPosition());
|
||||
player->m_movementInfo.time = GameTime::GetGameTimeMS();
|
||||
WorldPacket update(SMSG_MOVE_UPDATE);
|
||||
player->WriteMovementInfo(update);
|
||||
player->SendMessageToSet(&update, true);
|
||||
}
|
||||
char const* PhaseName(ClientlessTransportPhase phase)
|
||||
{
|
||||
switch (phase)
|
||||
{
|
||||
case ClientlessTransportPhase::Waiting: return "Waiting";
|
||||
case ClientlessTransportPhase::Boarding: return "Boarding";
|
||||
case ClientlessTransportPhase::Riding: return "Riding";
|
||||
case ClientlessTransportPhase::Leaving: return "Leaving";
|
||||
case ClientlessTransportPhase::Arrived: return "Arrived";
|
||||
case ClientlessTransportPhase::Failed: return "Failed";
|
||||
}
|
||||
return "Unknown";
|
||||
}
|
||||
bool Safe(std::string const& s) { return ClientlessRouteRecorderMgr::IsSafeKey(s); }
|
||||
float Distance(Position const& a, Position const& b) { return a.GetExactDist(&b); }
|
||||
uint32 Spawn(GameObject const* go) { return go->ToTransport() ? go->GetGUID().GetCounter() : go->GetSpawnId(); }
|
||||
bool SameService(ClientlessTransportStop const& a, ClientlessTransportStop const& b)
|
||||
{
|
||||
return a.Entry == b.Entry && a.Spawn == b.Spawn;
|
||||
}
|
||||
Position WorldPoint(GameObject* object, ClientlessTransportPoint const& point)
|
||||
{
|
||||
Position result = point.Pos;
|
||||
if (point.OnTransport)
|
||||
{
|
||||
float x, y, z, o;
|
||||
result.GetPosition(x, y, z, o);
|
||||
object->ToTransportBase()->CalculatePassengerPosition(x, y, z, &o);
|
||||
result.Relocate(x, y, z, o);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
GameObject* Resolve(Player* player, uint32 spawn)
|
||||
{
|
||||
if (!player || !player->IsInWorld())
|
||||
return nullptr;
|
||||
GameObject* go = player->GetMap()->GetGameObjectBySpawnId(spawn);
|
||||
return go ? go : player->GetMap()->GetTransport(ObjectGuid(HighGuid::Mo_Transport, spawn));
|
||||
}
|
||||
bool LoadNode(std::string const& key, uint32& map, Position& pos)
|
||||
{
|
||||
if (!Safe(key))
|
||||
return false;
|
||||
QueryResult r = WorldDatabase.PQuery("SELECT w.map,w.position_x,w.position_y,w.position_z,w.orientation "
|
||||
"FROM vcore_clientless_travel_node n JOIN vcore_clientless_world_point w ON w.point_key=n.world_point_key "
|
||||
"WHERE n.node_key='%s' AND n.enabled=1 AND w.enabled=1", key.c_str());
|
||||
if (!r)
|
||||
return false;
|
||||
Field* f = r->Fetch();
|
||||
// vcore_clientless_world_point.map is SMALLINT, not INT. Field access
|
||||
// must match its width; GetUInt32 returns zero in a debug build.
|
||||
map = f[0].GetUInt16();
|
||||
pos.Relocate(f[1].GetFloat(), f[2].GetFloat(), f[3].GetFloat(), f[4].GetFloat());
|
||||
return true;
|
||||
}
|
||||
float PathLength(ClientlessTransportStop const& stop)
|
||||
{
|
||||
float sum = 0;
|
||||
for (std::size_t i = 1; i < stop.Points.size(); ++i)
|
||||
{
|
||||
auto world = [&](ClientlessTransportPoint const& p)
|
||||
{
|
||||
Position pos = p.Pos;
|
||||
if (p.OnTransport)
|
||||
{
|
||||
float x, y, z, o;
|
||||
pos.GetPosition(x, y, z, o);
|
||||
TransportBase::CalculatePassengerPosition(x, y, z, &o,
|
||||
stop.Dock.GetPositionX(), stop.Dock.GetPositionY(), stop.Dock.GetPositionZ(), stop.Dock.GetOrientation());
|
||||
pos.Relocate(x, y, z, o);
|
||||
}
|
||||
return pos;
|
||||
};
|
||||
sum += Distance(world(stop.Points[i - 1]), world(stop.Points[i]));
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
}
|
||||
|
||||
ClientlessTransportMgr* ClientlessTransportMgr::instance()
|
||||
{
|
||||
static ClientlessTransportMgr mgr;
|
||||
return &mgr;
|
||||
}
|
||||
|
||||
bool ClientlessTransportMgr::IsTransportEdge(std::string const& type)
|
||||
{
|
||||
return type == "ship" || type == "zeppelin" || type == "tram" || type == "elevator";
|
||||
}
|
||||
|
||||
bool ClientlessTransportMgr::EnsureLoaded()
|
||||
{
|
||||
return _loaded ? _available : Reload();
|
||||
}
|
||||
|
||||
bool ClientlessTransportMgr::Reload()
|
||||
{
|
||||
_stops.clear();
|
||||
_loaded = true;
|
||||
_available = false;
|
||||
// Keep existing CPC installations usable before the explicit migration.
|
||||
if (!WorldDatabase.Query("SHOW TABLES LIKE 'vcore_clientless_transport_stop'") ||
|
||||
!WorldDatabase.Query("SHOW TABLES LIKE 'vcore_clientless_transport_point'") ||
|
||||
!WorldDatabase.Query("SHOW TABLES LIKE 'vcore_clientless_transport_stand'"))
|
||||
return false;
|
||||
_available = true;
|
||||
QueryResult r = WorldDatabase.Query("SELECT node_key,transport_entry,transport_spawn,map,dock_x,dock_y,dock_z,dock_o,period_ms,stand_name FROM vcore_clientless_transport_stop");
|
||||
if (r)
|
||||
do
|
||||
{
|
||||
Field* f = r->Fetch();
|
||||
ClientlessTransportStop stop;
|
||||
stop.Node = f[0].GetString(); stop.Entry = f[1].GetUInt32(); stop.Spawn = f[2].GetUInt32(); stop.Map = f[3].GetUInt32();
|
||||
stop.Dock.Relocate(f[4].GetFloat(), f[5].GetFloat(), f[6].GetFloat(), f[7].GetFloat());
|
||||
stop.Period = f[8].GetUInt32(); stop.Stand = f[9].GetString();
|
||||
if (Safe(stop.Node) && stop.Entry && stop.Spawn && stop.Period)
|
||||
_stops.emplace(stop.Node, std::move(stop));
|
||||
} while (r->NextRow());
|
||||
r = WorldDatabase.Query("SELECT node_key,point_index,on_transport,x,y,z,o FROM vcore_clientless_transport_point ORDER BY node_key,point_index");
|
||||
if (r)
|
||||
do
|
||||
{
|
||||
Field* f = r->Fetch();
|
||||
auto itr = _stops.find(f[0].GetString());
|
||||
if (itr == _stops.end())
|
||||
continue;
|
||||
if (f[1].GetUInt32() != itr->second.Points.size())
|
||||
{
|
||||
itr->second.Period = 0;
|
||||
continue;
|
||||
}
|
||||
ClientlessTransportPoint p;
|
||||
p.OnTransport = f[2].GetBool();
|
||||
p.Pos.Relocate(f[3].GetFloat(), f[4].GetFloat(), f[5].GetFloat(), f[6].GetFloat());
|
||||
itr->second.Points.push_back(p);
|
||||
} while (r->NextRow());
|
||||
for (auto itr = _stops.begin(); itr != _stops.end(); )
|
||||
{
|
||||
auto const& stop = itr->second;
|
||||
uint32 map = 0; Position node;
|
||||
bool valid = stop.Period && stop.Points.size() >= 2 && stop.Points.size() <= 512 &&
|
||||
!stop.Points.front().OnTransport && stop.Points.back().OnTransport &&
|
||||
LoadNode(stop.Node, map, node) && map == stop.Map && Distance(node, stop.Points.front().Pos) <= 3.0f;
|
||||
bool boarded = false;
|
||||
for (auto const& p : stop.Points)
|
||||
{
|
||||
if (!Trinity::IsValidMapCoord(p.Pos.GetPositionX(), p.Pos.GetPositionY(), p.Pos.GetPositionZ(), p.Pos.GetOrientation()) ||
|
||||
(boarded && !p.OnTransport) ||
|
||||
(p.OnTransport && (std::fabs(p.Pos.GetPositionX()) > 100 ||
|
||||
std::fabs(p.Pos.GetPositionY()) > 100 || std::fabs(p.Pos.GetPositionZ()) > 100))) valid = false;
|
||||
boarded |= p.OnTransport;
|
||||
}
|
||||
if (!valid || !std::isfinite(PathLength(stop)) || PathLength(stop) > 60.0f)
|
||||
{
|
||||
TC_LOG_ERROR("server.adventurebot.navigation", "TRANSPORT_STOP_INVALID node='%s'", stop.Node.c_str());
|
||||
itr = _stops.erase(itr);
|
||||
}
|
||||
else ++itr;
|
||||
}
|
||||
TC_LOG_INFO("server.adventurebot.navigation", "TRANSPORT_STOPS_LOADED count=%u", uint32(_stops.size()));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ClientlessTransportMgr::HasConnection(std::string const& from, std::string const& to)
|
||||
{
|
||||
EnsureLoaded();
|
||||
auto a = _stops.find(from), b = _stops.find(to);
|
||||
return a != _stops.end() && b != _stops.end() && SameService(a->second, b->second) &&
|
||||
(a->second.Map != b->second.Map || Distance(a->second.Dock, b->second.Dock) > 3.0f) &&
|
||||
Distance(a->second.Points.back().Pos, b->second.Points.back().Pos) <= 3.0f;
|
||||
}
|
||||
|
||||
GameObject* ClientlessTransportMgr::FindObject(Player* player, ClientlessTransportStop const& stop) const
|
||||
{
|
||||
if (!player || !player->IsInWorld()) return nullptr;
|
||||
GameObjectTemplate const* info = sObjectMgr->GetGameObjectTemplate(stop.Entry);
|
||||
GameObject* object = info && info->type == GAMEOBJECT_TYPE_MO_TRANSPORT
|
||||
? player->GetMap()->GetTransport(ObjectGuid(HighGuid::Mo_Transport, stop.Spawn))
|
||||
: player->GetMap()->GetGameObjectBySpawnId(stop.Spawn);
|
||||
return object && object->GetEntry() == stop.Entry && object->IsInWorld() &&
|
||||
object->ToTransportBase() && object->isSpawned() && player->IsInPhase(object) ? object : nullptr;
|
||||
}
|
||||
|
||||
bool ClientlessTransportMgr::AtDock(Player* player, GameObject* object, ClientlessTransportStop const& stop, uint32 requiredPause) const
|
||||
{
|
||||
TransportDockState state;
|
||||
return object && player->GetMapId() == stop.Map && object->GetMapId() == stop.Map &&
|
||||
Distance(*object, stop.Dock) <= 1.0f &&
|
||||
std::fabs(std::remainder(object->GetOrientation() - stop.Dock.GetOrientation(), float(2 * M_PI))) < 0.10f &&
|
||||
object->GetTransportDockState(state) && state.Stopped && state.RemainingPause >= requiredPause;
|
||||
}
|
||||
|
||||
bool ClientlessTransportMgr::Sample(Player* player, Recording& recording, bool force)
|
||||
{
|
||||
if (!recording.Error.empty())
|
||||
return false;
|
||||
auto fail = [&](char const* message) { recording.Error = message; return false; };
|
||||
if (!player->IsAlive() || player->IsFlying() || player->IsInFlight())
|
||||
return fail("Record with a living character on foot, not flying or on a taxi.");
|
||||
GameObject* object = FindObject(player, recording.Stop);
|
||||
if (!object || player->GetMapId() != recording.Stop.Map || object->GetGUID() != recording.Object ||
|
||||
!AtDock(player, object, recording.Stop, 0))
|
||||
return fail("Transport left its recorded dock; cancel and record during a complete stop.");
|
||||
bool aboard = player->GetTransport() == object->ToTransportBase();
|
||||
if ((player->GetTransport() && !aboard) || (recording.Attached && !aboard))
|
||||
return fail("Recorder changed or left the transport. Record land -> deck only.");
|
||||
ClientlessTransportPoint point;
|
||||
point.OnTransport = aboard;
|
||||
point.Pos.Relocate(player->GetPosition());
|
||||
if (aboard)
|
||||
{
|
||||
float x, y, z, o;
|
||||
point.Pos.GetPosition(x, y, z, o);
|
||||
object->ToTransportBase()->CalculatePassengerOffset(x, y, z, &o);
|
||||
if (std::fabs(x) > 100 || std::fabs(y) > 100 || std::fabs(z) > 100)
|
||||
return fail("Deck point is outside the supported transport bounds.");
|
||||
point.Pos.Relocate(x, y, z, o);
|
||||
}
|
||||
if (!recording.Stop.Points.empty())
|
||||
{
|
||||
auto const& previous = recording.Stop.Points.back();
|
||||
float delta = Distance(WorldPoint(object, previous), WorldPoint(object, point));
|
||||
if (delta > 8.0f)
|
||||
return fail("Position jump over 8 yards; do not teleport or fly while recording.");
|
||||
if (!force && previous.OnTransport == point.OnTransport && delta < 0.75f)
|
||||
return true;
|
||||
}
|
||||
if (recording.Stop.Points.size() >= 512)
|
||||
return fail("Recording exceeds 512 points.");
|
||||
recording.Stop.Points.push_back(point);
|
||||
recording.Attached |= aboard;
|
||||
if (PathLength(recording.Stop) > 60.0f)
|
||||
return fail("Dock transition exceeds 60 yards; move the shared node closer to the ramp.");
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ClientlessTransportMgr::Save(Player* player, std::string const& stand, std::string& error)
|
||||
{
|
||||
auto itr = _recordings.find(player->GetGUID().GetCounter());
|
||||
if (itr == _recordings.end()) { error = "No active recording."; return false; }
|
||||
Recording& recording = itr->second;
|
||||
if (!Safe(stand) || !Sample(player, recording, true) || !recording.Attached || recording.Stop.Points.size() < 2 || player->IsFalling())
|
||||
{
|
||||
error = recording.Error.empty() ? "Finish aboard the recorded transport and specify a valid stand name." : recording.Error;
|
||||
return false;
|
||||
}
|
||||
auto& stop = recording.Stop;
|
||||
if (WorldDatabase.PQuery("SELECT 1 FROM vcore_clientless_transport_stop WHERE node_key='%s'", stop.Node.c_str()))
|
||||
{ error = "This dock has already been saved; explicitly forget it before replacing a recording."; return false; }
|
||||
uint32 map = 0; Position node;
|
||||
if (!LoadNode(stop.Node, map, node) || map != stop.Map || Distance(node, stop.Points.front().Pos) > 0.25f)
|
||||
{ error = "Shared node changed during recording; cancel and start again."; return false; }
|
||||
auto const& pos = stop.Points.back().Pos;
|
||||
QueryResult existing = WorldDatabase.PQuery("SELECT x,y,z FROM vcore_clientless_transport_stand WHERE transport_entry=%u AND name='%s'", stop.Entry, stand.c_str());
|
||||
if (existing)
|
||||
{
|
||||
Field* f = existing->Fetch(); Position target;
|
||||
target.Relocate(f[0].GetFloat(), f[1].GetFloat(), f[2].GetFloat());
|
||||
if (Distance(pos, target) > 1.5f) { error = "Stand name already exists at another deck point; stand there or use another name."; return false; }
|
||||
}
|
||||
WorldDatabaseTransaction trans = WorldDatabase.BeginTransaction();
|
||||
trans->PAppend("DELETE FROM vcore_clientless_transport_point WHERE node_key='%s'", stop.Node.c_str());
|
||||
trans->PAppend("INSERT INTO vcore_clientless_transport_stop VALUES ('%s',%u,%u,%u,%.9f,%.9f,%.9f,%.9f,%u,'%s')",
|
||||
stop.Node.c_str(), stop.Entry, stop.Spawn, stop.Map, stop.Dock.GetPositionX(), stop.Dock.GetPositionY(), stop.Dock.GetPositionZ(), stop.Dock.GetOrientation(), stop.Period, stand.c_str());
|
||||
uint32 index = 0;
|
||||
for (auto const& p : stop.Points)
|
||||
trans->PAppend("INSERT INTO vcore_clientless_transport_point VALUES ('%s',%u,%u,%.9f,%.9f,%.9f,%.9f)",
|
||||
stop.Node.c_str(), index++, p.OnTransport ? 1u : 0u, p.Pos.GetPositionX(), p.Pos.GetPositionY(), p.Pos.GetPositionZ(), p.Pos.GetOrientation());
|
||||
trans->PAppend("INSERT IGNORE INTO vcore_clientless_transport_stand VALUES (%u,'%s',%.9f,%.9f,%.9f,%.9f)", stop.Entry, stand.c_str(),
|
||||
pos.GetPositionX(), pos.GetPositionY(), pos.GetPositionZ(), pos.GetOrientation());
|
||||
WorldDatabase.DirectCommitTransaction(trans);
|
||||
std::string nodeKey = stop.Node;
|
||||
std::size_t pointCount = stop.Points.size();
|
||||
if (!Reload() || !_stops.count(nodeKey) || _stops.at(nodeKey).Points.size() != pointCount)
|
||||
{ error = "Recorded stop could not be reloaded; inspect database errors. Unsaved recording retained."; return false; }
|
||||
_recordings.erase(itr);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ClientlessTransportMgr::Start(Player* player, std::string const& from, std::string const& to, std::string& error)
|
||||
{
|
||||
if (!player || !player->IsInWorld() || player->GetTransport() || !HasConnection(from, to))
|
||||
{ error = "Transport stops are not recorded/compatible, or player is already aboard."; return false; }
|
||||
auto const& source = _stops.at(from);
|
||||
if (source.Map != player->GetMapId() || Distance(*player, source.Points.front().Pos) > 3.0f)
|
||||
{ error = "Player has not reached the shared departure node."; return false; }
|
||||
Ride ride;
|
||||
ride.From = source; ride.To = _stops.at(to);
|
||||
ride.RequiredPause = uint32(PathLength(source) / std::max(1.0f, player->GetSpeed(MOVE_RUN)) * 1000.0f) + uint32(source.Points.size()) * 100 + 1500;
|
||||
ride.Status.Phase = ClientlessTransportPhase::Waiting;
|
||||
ride.Status.Detail = "waiting for recorded transport at departure dock";
|
||||
sClientlessTravelMgr->CancelTravel(player->GetGUID().GetCounter());
|
||||
player->StopMoving();
|
||||
_rides[player->GetGUID().GetCounter()] = std::move(ride);
|
||||
TC_LOG_INFO("server.adventurebot.navigation", "TRANSPORT_WAIT guid=%u from='%s' to='%s' spawn=%u boardingMs=%u",
|
||||
player->GetGUID().GetCounter(), from.c_str(), to.c_str(), source.Spawn, _rides.at(player->GetGUID().GetCounter()).RequiredPause);
|
||||
return true;
|
||||
}
|
||||
|
||||
ClientlessTransportStatus const* ClientlessTransportMgr::GetStatus(uint32 guid) const
|
||||
{
|
||||
auto itr = _rides.find(guid);
|
||||
return itr == _rides.end() ? nullptr : &itr->second.Status;
|
||||
}
|
||||
|
||||
void ClientlessTransportMgr::SetPhase(Player* player, Ride& ride, ClientlessTransportPhase phase, char const* detail)
|
||||
{
|
||||
ride.Status = { phase, detail, ride.Abort }; ride.PhaseElapsed = 0; ride.Moving = false;
|
||||
TC_LOG_INFO("server.adventurebot.navigation", "TRANSPORT_STATE guid=%u phase='%s' from='%s' to='%s' passenger=%u detail='%s'",
|
||||
player->GetGUID().GetCounter(), PhaseName(phase), ride.From.Node.c_str(), ride.To.Node.c_str(), player->GetTransport() ? 1u : 0u, detail);
|
||||
}
|
||||
|
||||
void ClientlessTransportMgr::Cancel(uint32 guid)
|
||||
{
|
||||
auto itr = _rides.find(guid);
|
||||
if (itr != _rides.end()) itr->second.Abort = true;
|
||||
}
|
||||
|
||||
void ClientlessTransportMgr::UpdateRide(Player* player, Ride& ride, uint32 diff)
|
||||
{
|
||||
using Phase = ClientlessTransportPhase;
|
||||
if (ride.Status.Phase == Phase::Arrived || ride.Status.Phase == Phase::Failed) return;
|
||||
ride.Elapsed += diff; ride.PhaseElapsed += diff;
|
||||
if (player->IsBeingTeleported())
|
||||
{
|
||||
player->GetSession()->CompleteClientlessTeleport();
|
||||
return;
|
||||
}
|
||||
GameObject* object = FindObject(player, ride.From);
|
||||
uint32 limit = uint32(std::min<uint64>(1800000, std::max<uint64>(180000, uint64(ride.From.Period) * 3 + 30000)));
|
||||
auto fail = [&](char const* detail)
|
||||
{
|
||||
player->StopMoving();
|
||||
SetPhase(player, ride, Phase::Failed, detail);
|
||||
// Never detach an actual passenger in midair or in the sea.
|
||||
};
|
||||
if (ride.Elapsed > limit) { fail("transport timeout; any passenger attachment retained"); return; }
|
||||
if (ride.Status.Phase == Phase::Waiting)
|
||||
{
|
||||
if (ride.Abort) { fail("transport cancelled on land"); return; }
|
||||
if (!AtDock(player, object, ride.From, ride.RequiredPause)) return;
|
||||
if (Distance(*player, ride.From.Points.front().Pos) > 3.0f)
|
||||
{ fail("player moved away from departure node while waiting"); return; }
|
||||
ride.Point = 0;
|
||||
SetPhase(player, ride, Phase::Boarding, "boarding recorded land-to-deck transition");
|
||||
}
|
||||
if (ride.Status.Phase == Phase::Boarding && ride.Abort)
|
||||
{
|
||||
player->StopMoving();
|
||||
if (!player->GetTransport()) { fail("transport cancelled on land"); return; }
|
||||
SetPhase(player, ride, Phase::Riding, "boarding cancelled; waiting for safe dock");
|
||||
}
|
||||
if (ride.Status.Phase == Phase::Riding)
|
||||
{
|
||||
if (!object || player->GetTransport() != object->ToTransportBase())
|
||||
{ fail("transport passenger identity lost"); return; }
|
||||
ClientlessTransportStop const* arrival = nullptr;
|
||||
if (AtDock(player, object, ride.To, uint32(PathLength(ride.To) / std::max(1.0f, player->GetSpeed(MOVE_RUN)) * 1000) + uint32(ride.To.Points.size()) * 100 + 1500)) arrival = &ride.To;
|
||||
else if (ride.Abort && AtDock(player, object, ride.From, ride.RequiredPause)) arrival = &ride.From;
|
||||
if (!arrival) return;
|
||||
if (arrival == &ride.From) ride.To = ride.From;
|
||||
ride.Point = ride.To.Points.size();
|
||||
SetPhase(player, ride, Phase::Leaving, "leaving via reversed recorded transition");
|
||||
}
|
||||
if (ride.Status.Phase != Phase::Boarding && ride.Status.Phase != Phase::Leaving) return;
|
||||
bool boarding = ride.Status.Phase == Phase::Boarding;
|
||||
auto const& stop = boarding ? ride.From : ride.To;
|
||||
if (!object || !AtDock(player, object, stop, 0))
|
||||
{
|
||||
if (player->GetTransport())
|
||||
{
|
||||
player->StopMoving(); ride.Abort = true;
|
||||
SetPhase(player, ride, Phase::Riding, "departure during transition; retaining passenger until safe dock");
|
||||
}
|
||||
else fail("transport departed during land transition");
|
||||
return;
|
||||
}
|
||||
if (ride.Moving)
|
||||
{
|
||||
if (player->IsSplineEnabled())
|
||||
{
|
||||
if (ride.PhaseElapsed > ride.PointTimeout) fail("recorded transition spline timeout");
|
||||
return;
|
||||
}
|
||||
auto const& previous = stop.Points[boarding ? ride.Point - 1 : ride.Point];
|
||||
if (Distance(*player, WorldPoint(object, previous)) > 1.5f)
|
||||
{ fail("recorded transition stopped before target point"); return; }
|
||||
if (!previous.OnTransport && player->GetTransport() == object->ToTransportBase())
|
||||
{
|
||||
object->ToTransportBase()->RemovePassenger(player);
|
||||
PublishPassengerState(player);
|
||||
}
|
||||
ride.Moving = false;
|
||||
}
|
||||
if ((boarding && ride.Point >= stop.Points.size()) || (!boarding && ride.Point == 0))
|
||||
{
|
||||
if (boarding)
|
||||
{
|
||||
if (player->GetTransport() != object->ToTransportBase()) { fail("boarding did not attach passenger"); return; }
|
||||
SetPhase(player, ride, Phase::Riding, "aboard; waiting for destination dock");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (player->GetTransport() || Distance(*player, stop.Points.front().Pos) > 2.0f)
|
||||
{ fail("shared arrival node not reached on land"); return; }
|
||||
SetPhase(player, ride, ride.Abort ? Phase::Failed : Phase::Arrived, ride.Abort ? "cancelled at safe dock" : "arrived at shared destination node");
|
||||
}
|
||||
return;
|
||||
}
|
||||
auto const& point = stop.Points[boarding ? ride.Point : ride.Point - 1];
|
||||
Position target = WorldPoint(object, point);
|
||||
if (Distance(*player, target) > 8.0f) { fail("recorded transition has an unsafe gap over 8 yards"); return; }
|
||||
bool attached = player->GetTransport() == object->ToTransportBase();
|
||||
if (point.OnTransport && !attached)
|
||||
{
|
||||
if (player->GetTransport()) { fail("another passenger owner already attached"); return; }
|
||||
float x, y, z, o;
|
||||
player->GetPosition(x, y, z, o);
|
||||
object->ToTransportBase()->CalculatePassengerOffset(x, y, z, &o);
|
||||
if (std::fabs(x) > 100 || std::fabs(y) > 100 || std::fabs(z) > 100)
|
||||
{ fail("passenger attachment outside supported local bounds"); return; }
|
||||
player->m_movementInfo.transport.Reset();
|
||||
player->m_movementInfo.transport.pos.Relocate(x, y, z, o);
|
||||
TransportDockState state; object->GetTransportDockState(state);
|
||||
player->m_movementInfo.transport.time = state.Progress;
|
||||
object->ToTransportBase()->AddPassenger(player);
|
||||
if (player->GetTransport() != object->ToTransportBase())
|
||||
{ fail("native passenger attachment failed"); return; }
|
||||
PublishPassengerState(player);
|
||||
}
|
||||
// Keep ownership until the first land point is physically reached. If
|
||||
// departure interrupts disembarkation, the bot remains a real passenger.
|
||||
if (!point.OnTransport && attached && Distance(*player, target) <= 0.1f)
|
||||
{
|
||||
object->ToTransportBase()->RemovePassenger(player);
|
||||
PublishPassengerState(player);
|
||||
}
|
||||
Position path[2]; path[0].Relocate(player->GetPosition()); path[1] = target;
|
||||
if (Distance(path[0], path[1]) > 0.1f)
|
||||
player->GetMotionMaster()->MoveSmoothPath(1, path, 2, false);
|
||||
ride.Moving = Distance(path[0], path[1]) > 0.1f; ride.PhaseElapsed = 0;
|
||||
ride.PointTimeout = 10000;
|
||||
if (boarding) ++ride.Point; else --ride.Point;
|
||||
}
|
||||
|
||||
void ClientlessTransportMgr::Update(uint32 diff)
|
||||
{
|
||||
for (auto itr = _recordings.begin(); itr != _recordings.end(); )
|
||||
{
|
||||
Player* player = ObjectAccessor::FindConnectedPlayer(ObjectGuid(HighGuid::Player, itr->first));
|
||||
if (!player || !player->IsInWorld()) { itr = _recordings.erase(itr); continue; }
|
||||
Recording& recording = itr->second;
|
||||
recording.Timer += diff;
|
||||
if (recording.Timer >= 100)
|
||||
{
|
||||
recording.Timer = 0;
|
||||
if (recording.Error.empty() && !Sample(player, recording, false) && !recording.Error.empty())
|
||||
{
|
||||
TC_LOG_WARN("server.adventurebot.navigation", "TRANSPORT_RECORD_FAILED recorder=%u detail='%s'", itr->first, recording.Error.c_str());
|
||||
// Keep the buffer and diagnostic; no further automatic samples.
|
||||
recording.Timer = 0;
|
||||
}
|
||||
}
|
||||
++itr;
|
||||
}
|
||||
for (auto itr = _rides.begin(); itr != _rides.end(); )
|
||||
{
|
||||
WorldSession* session = sWorld->FindClientlessSession(itr->first);
|
||||
if (!session || sWorld->GetClientlessPlayerState(itr->first) != ClientlessPlayerState::Online)
|
||||
{ itr = _rides.erase(itr); continue; }
|
||||
Player* player = session->GetPlayer();
|
||||
if (player) UpdateRide(player, itr->second, diff);
|
||||
++itr;
|
||||
}
|
||||
}
|
||||
|
||||
bool ClientlessTransportMgr::Command(ChatHandler* handler, char const* args)
|
||||
{
|
||||
std::istringstream input(args ? args : "");
|
||||
std::string command; input >> command;
|
||||
Player* gm = handler->GetSession() ? handler->GetSession()->GetPlayer() : nullptr;
|
||||
if (command == "reload") Reload();
|
||||
if (!EnsureLoaded())
|
||||
{
|
||||
handler->SendSysMessage("Transport schema unavailable. Apply sql/custom/world/2026_10_25_00_clientless_transport.sql to the test world DB.");
|
||||
return true;
|
||||
}
|
||||
auto say = [&](std::string const& text) { handler->SendSysMessage(text.c_str()); };
|
||||
if (command == "services")
|
||||
{
|
||||
uint32 requested = 0;
|
||||
input >> std::ws;
|
||||
if (input.peek() != std::char_traits<char>::eof())
|
||||
{
|
||||
std::string extra;
|
||||
if (!(input >> requested) || !requested || (input >> extra))
|
||||
{ say("Usage: .clientless transport services [spawnId]"); return true; }
|
||||
}
|
||||
QueryResult rows = requested
|
||||
? WorldDatabase.PQuery("SELECT guid,entry FROM transports WHERE guid=%u", requested)
|
||||
: WorldDatabase.Query("SELECT guid,entry FROM transports ORDER BY guid");
|
||||
if (!rows) { say("No configured native transport service found."); return true; }
|
||||
do
|
||||
{
|
||||
Field* fields = rows->Fetch();
|
||||
uint32 spawn = fields[0].GetUInt32(), entry = fields[1].GetUInt32();
|
||||
auto info = sObjectMgr->GetGameObjectTemplate(entry);
|
||||
auto route = sTransportMgr->GetTransportTemplate(entry);
|
||||
if (!info || !route) continue;
|
||||
uint32 stops = 0;
|
||||
for (auto const& leg : route->PathLegs)
|
||||
for (auto const& segment : leg.Segments)
|
||||
if (segment.Delay) ++stops;
|
||||
handler->PSendSysMessage("Service spawn=%u entry=%u name='%s' periodMs=%u stops=%u legs=%u instanced=%u",
|
||||
spawn, entry, info->name.c_str(), route->TotalPathTime, stops, uint32(route->PathLegs.size()), route->InInstance ? 1u : 0u);
|
||||
if (!requested) continue;
|
||||
uint32 dock = 0;
|
||||
for (auto const& leg : route->PathLegs)
|
||||
for (auto const& segment : leg.Segments)
|
||||
{
|
||||
if (!segment.Delay) continue;
|
||||
TransportMovementState state;
|
||||
size_t index;
|
||||
auto position = route->ComputePosition(segment.SegmentEndArrivalTimestamp, &state, &index);
|
||||
if (!position || state != TransportMovementState::WaitingOnPauseWaypoint || index >= route->PathLegs.size())
|
||||
continue;
|
||||
handler->PSendSysMessage(" Dock %u: map=%u pose=(%.3f %.3f %.3f %.3f) arrivalMs=%u pauseMs=%u",
|
||||
++dock, route->PathLegs[index].MapId, position->GetPositionX(), position->GetPositionY(),
|
||||
position->GetPositionZ(), position->GetOrientation(), segment.SegmentEndArrivalTimestamp, segment.Delay);
|
||||
}
|
||||
if (!dock) say(" No scheduled boarding pauses. Do not create a regular passenger edge without a verified stop.");
|
||||
} while (rows->NextRow());
|
||||
return true;
|
||||
}
|
||||
if (command == "reload")
|
||||
{
|
||||
Reload(); sClientlessTravelGraphMgr->Reload();
|
||||
handler->PSendSysMessage("Transport data reloaded: %u stops. Active rides retain their captured definitions.", uint32(_stops.size()));
|
||||
return true;
|
||||
}
|
||||
if (command == "node")
|
||||
{
|
||||
std::string node; input >> node;
|
||||
uint32 map = 0; Position position;
|
||||
if (!LoadNode(node, map, position))
|
||||
say("Transport node lookup failed: specify an existing enabled node.");
|
||||
else handler->PSendSysMessage("Transport node '%s': map=%u position=(%.3f %.3f %.3f %.3f)",
|
||||
node.c_str(), map, position.GetPositionX(), position.GetPositionY(), position.GetPositionZ(), position.GetOrientation());
|
||||
return true;
|
||||
}
|
||||
if (command == "status" || command == "cancel" || command == "travel")
|
||||
{
|
||||
uint32 guid = 0; input >> guid;
|
||||
if (!guid) { say("Usage: .clientless transport status|cancel <guid> OR travel <guid> <destinationNode>"); return true; }
|
||||
if (command == "cancel")
|
||||
{
|
||||
auto const* status = GetStatus(guid);
|
||||
if (!status || status->Phase == ClientlessTransportPhase::Arrived || status->Phase == ClientlessTransportPhase::Failed)
|
||||
say("No active transport ride to cancel.");
|
||||
else { Cancel(guid); say("Cancellation requested; a passenger remains attached until a safe recorded stop."); }
|
||||
return true;
|
||||
}
|
||||
if (command == "travel")
|
||||
{
|
||||
std::string node; input >> node;
|
||||
if (!Safe(node)) { say("Specify a valid destination node."); return true; }
|
||||
ClientlessAction action; action.Name = "travel_to_node";
|
||||
action.Parameters["node"] = node; action.Parameters["hard_destination"] = "1";
|
||||
say(sClientlessPlayerMgr->QueueAction(guid, std::move(action)) ? "Travel action queued through the regular graph executor." : "Character is not CPC managed.");
|
||||
return true;
|
||||
}
|
||||
auto itr = _rides.find(guid);
|
||||
if (itr == _rides.end()) say("No transport ride state for this character.");
|
||||
else handler->PSendSysMessage("Transport GUID %u: phase=%s from='%s' to='%s' spawn=%u point=%u elapsedMs=%u abort=%u detail='%s'",
|
||||
guid, PhaseName(itr->second.Status.Phase), itr->second.From.Node.c_str(), itr->second.To.Node.c_str(), itr->second.From.Spawn,
|
||||
uint32(itr->second.Point), itr->second.Elapsed, itr->second.Abort ? 1u : 0u, itr->second.Status.Detail.c_str());
|
||||
return true;
|
||||
}
|
||||
if (command == "stops")
|
||||
{
|
||||
for (auto const& pair : _stops)
|
||||
{
|
||||
auto const& s = pair.second;
|
||||
handler->PSendSysMessage("Stop '%s': entry=%u spawn=%u map=%u points=%u length=%.2f stand='%s' period=%ums dock=(%.3f %.3f %.3f)",
|
||||
s.Node.c_str(), s.Entry, s.Spawn, s.Map, uint32(s.Points.size()), PathLength(s), s.Stand.c_str(), s.Period,
|
||||
s.Dock.GetPositionX(), s.Dock.GetPositionY(), s.Dock.GetPositionZ());
|
||||
}
|
||||
handler->PSendSysMessage("Recorded transport stops: %u", uint32(_stops.size()));
|
||||
return true;
|
||||
}
|
||||
if (command == "forget")
|
||||
{
|
||||
std::string node; input >> node;
|
||||
if (!Safe(node)) { say("Usage: .clientless transport forget <node> (removes only the recorded dock transition)"); return true; }
|
||||
WorldDatabaseTransaction trans = WorldDatabase.BeginTransaction();
|
||||
trans->PAppend("DELETE FROM vcore_clientless_transport_point WHERE node_key='%s'", node.c_str());
|
||||
trans->PAppend("DELETE FROM vcore_clientless_transport_stop WHERE node_key='%s'", node.c_str());
|
||||
WorldDatabase.DirectCommitTransaction(trans); Reload(); sClientlessTravelGraphMgr->Reload();
|
||||
say("Dock recording removed. Shared node, ground routes and deck stands retained; incomplete transport connections are not planned.");
|
||||
return true;
|
||||
}
|
||||
if (command == "link")
|
||||
{
|
||||
std::string from, to, type, both; float cost = 0;
|
||||
input >> from >> to >> type >> cost >> both;
|
||||
if (!Safe(from) || !Safe(to) || !IsTransportEdge(type) || !std::isfinite(cost) || cost <= 0 || cost > 100000000 ||
|
||||
(!both.empty() && both != "both") || !HasConnection(from, to))
|
||||
{ say("Usage: .clientless transport link <fromNode> <toNode> <ship|zeppelin|tram|elevator> <graphCost> [both]. Both stops must use the same spawn and deck stand."); return true; }
|
||||
auto const& a = _stops.at(from);
|
||||
GameObjectTemplate const* info = sObjectMgr->GetGameObjectTemplate(a.Entry);
|
||||
if (!info || ((type == "tram" || type == "elevator") ? info->type != GAMEOBJECT_TYPE_TRANSPORT : info->type != GAMEOBJECT_TYPE_MO_TRANSPORT))
|
||||
{ say("Edge type does not match the recorded GameObject transport implementation."); return true; }
|
||||
if (WorldDatabase.PQuery("SELECT id FROM vcore_clientless_travel_edge WHERE from_node_key='%s' AND to_node_key='%s' AND edge_type='%s'", from.c_str(), to.c_str(), type.c_str()))
|
||||
{ say("This directed transport edge already exists; it was not overwritten."); return true; }
|
||||
WorldDatabase.DirectPExecute("INSERT INTO vcore_clientless_travel_edge (from_node_key,to_node_key,edge_type,cost,bidirectional,enabled,comment) "
|
||||
"VALUES ('%s','%s','%s',%.6f,%u,1,'CPC recorded native transport')", from.c_str(), to.c_str(), type.c_str(), cost, both == "both" ? 1u : 0u);
|
||||
sClientlessTravelGraphMgr->Reload(); say("Transport edge created. The shared stop nodes are used in both directions.");
|
||||
return true;
|
||||
}
|
||||
if (command == "stand")
|
||||
{
|
||||
std::string operation, name; input >> operation;
|
||||
if (operation == "list")
|
||||
{
|
||||
uint32 entry = 0; input >> entry;
|
||||
if (!entry && gm && gm->GetTransport())
|
||||
if (GameObject* go = gm->GetMap()->GetGameObject(gm->GetTransport()->GetTransportGUID())) entry = go->GetEntry();
|
||||
if (!entry) { say("Usage: .clientless transport stand list <transportEntry>"); return true; }
|
||||
QueryResult r = WorldDatabase.PQuery("SELECT name,x,y,z,o FROM vcore_clientless_transport_stand WHERE transport_entry=%u ORDER BY name", entry);
|
||||
if (r) do { Field* f = r->Fetch(); handler->PSendSysMessage("Stand '%s' entry=%u local=(%.3f %.3f %.3f %.3f)", f[0].GetString().c_str(), entry, f[1].GetFloat(), f[2].GetFloat(), f[3].GetFloat(), f[4].GetFloat()); } while (r->NextRow());
|
||||
else say("No stands recorded for this entry.");
|
||||
return true;
|
||||
}
|
||||
if (operation == "remove")
|
||||
{
|
||||
uint32 entry = 0; input >> entry >> name;
|
||||
if (!entry || !Safe(name)) { say("Usage: .clientless transport stand remove <entry> <name>"); return true; }
|
||||
if (WorldDatabase.PQuery("SELECT node_key FROM vcore_clientless_transport_stop WHERE transport_entry=%u AND stand_name='%s' LIMIT 1", entry, name.c_str()))
|
||||
{ say("Stand is used by a stop. Forget/re-record those transitions before removing it."); return true; }
|
||||
WorldDatabase.DirectPExecute("DELETE FROM vcore_clientless_transport_stand WHERE transport_entry=%u AND name='%s'", entry, name.c_str());
|
||||
say("Unused deck stand removed."); return true;
|
||||
}
|
||||
input >> name;
|
||||
if (operation != "add" || !Safe(name) || !gm || !gm->GetTransport() || gm->IsFlying() || gm->IsFalling())
|
||||
{ say("Stand aboard the intended transport, then use .clientless transport stand add <name>."); return true; }
|
||||
GameObject* go = gm->GetMap()->GetGameObject(gm->GetTransport()->GetTransportGUID());
|
||||
if (!go || !go->ToTransportBase()) { say("Transport GameObject not found."); return true; }
|
||||
float x, y, z, o; gm->GetPosition(x, y, z, o); go->ToTransportBase()->CalculatePassengerOffset(x, y, z, &o);
|
||||
if (std::fabs(x) > 100 || std::fabs(y) > 100 || std::fabs(z) > 100) { say("Stand outside supported deck bounds."); return true; }
|
||||
if (WorldDatabase.PQuery("SELECT 1 FROM vcore_clientless_transport_stand WHERE transport_entry=%u AND name='%s'", go->GetEntry(), name.c_str()))
|
||||
{ say("Stand name already recorded; use a new name to preserve the existing point."); return true; }
|
||||
WorldDatabase.DirectPExecute("INSERT INTO vcore_clientless_transport_stand VALUES (%u,'%s',%.9f,%.9f,%.9f,%.9f)", go->GetEntry(), name.c_str(), x, y, z, o);
|
||||
handler->PSendSysMessage("Stand '%s' saved for entry=%u at local (%.3f %.3f %.3f %.3f).", name.c_str(), go->GetEntry(), x, y, z, o);
|
||||
return true;
|
||||
}
|
||||
if (command == "inspect" || command == "near")
|
||||
{
|
||||
uint32 spawn = 0, guid = 0;
|
||||
float radius = 50;
|
||||
if (command == "inspect") input >> spawn >> guid;
|
||||
else input >> radius >> guid;
|
||||
Player* observer = gm;
|
||||
if (!observer && guid)
|
||||
if (WorldSession* session = sWorld->FindClientlessSession(guid)) observer = session->GetPlayer();
|
||||
if (!observer || !observer->IsInWorld()) { say("In console use: transport inspect <spawnId> <botGuid> OR transport near <radius> <botGuid>."); return true; }
|
||||
auto print = [&](GameObject* go)
|
||||
{
|
||||
if (!go || !go->IsTransport() || !go->IsInWorld() || !observer->IsInPhase(go)) return;
|
||||
TransportDockState state; go->GetTransportDockState(state);
|
||||
handler->PSendSysMessage("Transport '%s': entry=%u spawn=%u guid='%s' map=%u pos=(%.3f %.3f %.3f %.3f) distance=%.2f stopped=%u remainingPause=%u period=%u progress=%u",
|
||||
go->GetName().c_str(), go->GetEntry(), Spawn(go), go->GetGUID().ToString().c_str(), go->GetMapId(), go->GetPositionX(), go->GetPositionY(), go->GetPositionZ(), go->GetOrientation(),
|
||||
observer->GetExactDist(go), state.Stopped ? 1u : 0u, state.RemainingPause, state.Period, state.Progress);
|
||||
};
|
||||
if (command == "inspect")
|
||||
{
|
||||
GameObject* go = Resolve(observer, spawn);
|
||||
if (go) print(go); else say("Spawn is not loaded on the observer's current map.");
|
||||
}
|
||||
else
|
||||
{
|
||||
radius = std::isfinite(radius) ? std::max(1.0f, std::min(200.0f, radius)) : 50;
|
||||
for (auto const& pair : observer->GetMap()->GetGameObjectBySpawnIdStore())
|
||||
if (observer->GetExactDist(pair.second) <= radius) print(pair.second);
|
||||
QueryResult r = WorldDatabase.Query("SELECT guid FROM transports");
|
||||
if (r) do { GameObject* go = observer->GetMap()->GetTransport(ObjectGuid(HighGuid::Mo_Transport, r->Fetch()[0].GetUInt32())); if (go && observer->GetExactDist(go) <= radius) print(go); } while (r->NextRow());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (command == "record")
|
||||
{
|
||||
if (!gm || !gm->IsInWorld()) { say("Dock recording needs an in-game GM player."); return true; }
|
||||
uint32 guid = gm->GetGUID().GetCounter();
|
||||
std::string op; input >> op;
|
||||
if (op == "cancel") { _recordings.erase(guid); say("Your unsaved recording was cancelled."); return true; }
|
||||
if (op == "info")
|
||||
{
|
||||
auto itr = _recordings.find(guid);
|
||||
if (itr == _recordings.end()) say("No active recording.");
|
||||
else handler->PSendSysMessage("Recording node='%s' spawn=%u points=%u length=%.2f aboard=%u error='%s'", itr->second.Stop.Node.c_str(), itr->second.Stop.Spawn,
|
||||
uint32(itr->second.Stop.Points.size()), PathLength(itr->second.Stop), itr->second.Attached ? 1u : 0u, itr->second.Error.c_str());
|
||||
return true;
|
||||
}
|
||||
if (op == "start")
|
||||
{
|
||||
std::string node; uint32 spawn = 0; input >> node >> spawn;
|
||||
if (_recordings.count(guid)) { say("Finish or cancel your existing recording first."); return true; }
|
||||
Position position; uint32 map = 0;
|
||||
if (!Safe(node) || !spawn)
|
||||
{ say("Usage: .clientless transport record start <sharedNode> <spawnId>"); return true; }
|
||||
if (!LoadNode(node, map, position))
|
||||
{ handler->PSendSysMessage("Recording not started: node '%s' does not exist or is disabled.", node.c_str()); return true; }
|
||||
if (map != gm->GetMapId())
|
||||
{ handler->PSendSysMessage("Recording not started: node map=%u, recorder map=%u.", map, gm->GetMapId()); return true; }
|
||||
float const nodeDistance = Distance(*gm, position);
|
||||
if (nodeDistance > 2.0f)
|
||||
{ handler->PSendSysMessage("Recording not started: distance to node '%s' is %.3f yd (maximum 2.000); node=(%.3f %.3f %.3f), recorder=(%.3f %.3f %.3f).",
|
||||
node.c_str(), nodeDistance, position.GetPositionX(), position.GetPositionY(), position.GetPositionZ(), gm->GetPositionX(), gm->GetPositionY(), gm->GetPositionZ()); return true; }
|
||||
if (gm->GetTransport())
|
||||
{ handler->PSendSysMessage("Recording not started: recorder is already a passenger of '%s'. Start on the platform, before boarding.",
|
||||
gm->GetTransport()->GetTransportGUID().ToString().c_str()); return true; }
|
||||
GameObject* go = Resolve(gm, spawn);
|
||||
if (!go)
|
||||
{ handler->PSendSysMessage("Recording not started: spawn %u is not loaded on map %u.", spawn, gm->GetMapId()); return true; }
|
||||
if (!go->ToTransportBase())
|
||||
{ handler->PSendSysMessage("Recording not started: spawn %u entry %u is not a supported transport.", spawn, go->GetEntry()); return true; }
|
||||
if (!gm->IsInPhase(go))
|
||||
{ say("Recording not started: recorder and transport are in different phases."); return true; }
|
||||
float const transportDistance = gm->GetExactDist(go);
|
||||
if (transportDistance > 100.0f)
|
||||
{ handler->PSendSysMessage("Recording not started: spawn %u is %.3f yd away (maximum 100.000); transport=(%.3f %.3f %.3f).",
|
||||
spawn, transportDistance, go->GetPositionX(), go->GetPositionY(), go->GetPositionZ()); return true; }
|
||||
TransportDockState state;
|
||||
if (!go->GetTransportDockState(state))
|
||||
{ handler->PSendSysMessage("Recording not started: no server dock state available for spawn %u entry %u.", spawn, go->GetEntry()); return true; }
|
||||
if (!state.Stopped)
|
||||
{ handler->PSendSysMessage("Recording not started: spawn %u is moving according to its animation; progress=%u/%u ms, remainingPause=%u ms, nodeDistance=%.3f yd, transportDistance=%.3f yd.",
|
||||
spawn, state.Progress, state.Period, state.RemainingPause, nodeDistance, transportDistance); return true; }
|
||||
if (WorldDatabase.PQuery("SELECT 1 FROM vcore_clientless_transport_stop WHERE node_key='%s'", node.c_str())) { say("Dock already recorded; use transport forget <node> explicitly before replacing it."); return true; }
|
||||
Recording recording;
|
||||
recording.Stop.Node = node; recording.Stop.Entry = go->GetEntry(); recording.Stop.Spawn = Spawn(go);
|
||||
recording.Stop.Map = map; recording.Stop.Period = state.Period; recording.Stop.Dock.Relocate(go->GetPosition());
|
||||
recording.Object = go->GetGUID(); recording.NodePosition = position;
|
||||
recording.Stop.Points.push_back({ false, position });
|
||||
_recordings[guid] = recording;
|
||||
say("Recording started. Walk normally onto this transport and to a deck stand during this stop. Then: transport record stop <standName>.");
|
||||
return true;
|
||||
}
|
||||
auto itr = _recordings.find(guid);
|
||||
if (itr == _recordings.end()) { say("No active recording."); return true; }
|
||||
if (op == "mark") { bool ok = Sample(gm, itr->second, true); say(ok ? "Transition point marked." : itr->second.Error); return true; }
|
||||
if (op == "stop")
|
||||
{
|
||||
std::string stand, error; input >> stand;
|
||||
if (Save(gm, stand, error)) { sClientlessTravelGraphMgr->Reload(); say("Dock transition and local deck stand saved. Record the other stop on the same physical transport, ending at the same stand."); }
|
||||
else say(error);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
say("Transport tools: services [spawn], node <key>, near [radius], inspect <spawn> [botGuid], stops, stand add|list, record start|mark|info|stop|cancel, link <from> <to> <type> <cost> [both], forget <node>, reload, status|cancel <guid>, travel <guid> <node>.");
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/* Vcore: recorded dock transitions and native clientless transport travel. */
|
||||
#ifndef VCORE_CLIENTLESS_TRANSPORT_MGR_H
|
||||
#define VCORE_CLIENTLESS_TRANSPORT_MGR_H
|
||||
|
||||
#include "Common.h"
|
||||
#include "ObjectGuid.h"
|
||||
#include "Position.h"
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
class Player;
|
||||
class GameObject;
|
||||
class ChatHandler;
|
||||
|
||||
struct ClientlessTransportPoint
|
||||
{
|
||||
bool OnTransport = false;
|
||||
Position Pos;
|
||||
};
|
||||
|
||||
struct ClientlessTransportStop
|
||||
{
|
||||
std::string Node;
|
||||
uint32 Entry = 0;
|
||||
uint32 Spawn = 0;
|
||||
uint32 Map = 0;
|
||||
uint32 Period = 0;
|
||||
Position Dock;
|
||||
std::string Stand;
|
||||
std::vector<ClientlessTransportPoint> Points;
|
||||
};
|
||||
|
||||
enum class ClientlessTransportPhase : uint8
|
||||
{
|
||||
Waiting, Boarding, Riding, Leaving, Arrived, Failed
|
||||
};
|
||||
|
||||
struct ClientlessTransportStatus
|
||||
{
|
||||
ClientlessTransportPhase Phase = ClientlessTransportPhase::Failed;
|
||||
std::string Detail;
|
||||
bool Cancelled = false;
|
||||
};
|
||||
|
||||
class TC_GAME_API ClientlessTransportMgr
|
||||
{
|
||||
public:
|
||||
static ClientlessTransportMgr* instance();
|
||||
bool Reload();
|
||||
bool HasConnection(std::string const& from, std::string const& to);
|
||||
bool Start(Player* player, std::string const& from, std::string const& to, std::string& error);
|
||||
ClientlessTransportStatus const* GetStatus(uint32 guid) const;
|
||||
void Cancel(uint32 guid);
|
||||
void Update(uint32 diff);
|
||||
bool Command(ChatHandler* handler, char const* args);
|
||||
static bool IsTransportEdge(std::string const& type);
|
||||
|
||||
private:
|
||||
struct Recording
|
||||
{
|
||||
ClientlessTransportStop Stop;
|
||||
ObjectGuid Object;
|
||||
Position NodePosition;
|
||||
bool Attached = false;
|
||||
std::string Error;
|
||||
uint32 Timer = 0;
|
||||
};
|
||||
struct Ride
|
||||
{
|
||||
ClientlessTransportStatus Status;
|
||||
ClientlessTransportStop From, To;
|
||||
std::size_t Point = 0;
|
||||
uint32 Elapsed = 0;
|
||||
uint32 PhaseElapsed = 0;
|
||||
uint32 RequiredPause = 0;
|
||||
uint32 PointTimeout = 0;
|
||||
bool Moving = false;
|
||||
bool Abort = false;
|
||||
};
|
||||
bool EnsureLoaded();
|
||||
bool Sample(Player* player, Recording& recording, bool force);
|
||||
bool Save(Player* player, std::string const& stand, std::string& error);
|
||||
void UpdateRide(Player* player, Ride& ride, uint32 diff);
|
||||
void SetPhase(Player* player, Ride& ride, ClientlessTransportPhase phase, char const* detail);
|
||||
GameObject* FindObject(Player* player, ClientlessTransportStop const& stop) const;
|
||||
bool AtDock(Player* player, GameObject* object, ClientlessTransportStop const& stop, uint32 requiredPause) const;
|
||||
|
||||
bool _loaded = false;
|
||||
bool _available = false;
|
||||
std::unordered_map<std::string, ClientlessTransportStop> _stops;
|
||||
std::unordered_map<uint32, Recording> _recordings;
|
||||
std::unordered_map<uint32, Ride> _rides;
|
||||
};
|
||||
|
||||
#define sClientlessTransportMgr ClientlessTransportMgr::instance()
|
||||
#endif
|
||||
@@ -1,10 +1,14 @@
|
||||
#include "ClientlessTravelGraphMgr.h"
|
||||
#include "ClientlessTransportMgr.h"
|
||||
|
||||
#include "DatabaseEnv.h"
|
||||
#include "Log.h"
|
||||
#include "Player.h"
|
||||
#include "ClientlessTravelMgr.h"
|
||||
#include "PathGenerator.h" // VCORE_C818_PREDICTIVE_GRAPH
|
||||
#include "ObjectMgr.h"
|
||||
#include "SpellMgr.h"
|
||||
#include "SpellInfo.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
@@ -26,11 +30,80 @@ bool ClientlessTravelGraphMgr::EnsureLoaded()
|
||||
return Reload();
|
||||
}
|
||||
|
||||
bool ClientlessTravelGraphMgr::ResolvePortalUse(uint32 spawnId,
|
||||
ClientlessPortalUse& portal, std::string& error)
|
||||
{
|
||||
portal = {};
|
||||
auto const* spawn = sObjectMgr->GetGameObjectData(spawnId);
|
||||
auto const* info = spawn ? sObjectMgr->GetGameObjectTemplate(spawn->id) : nullptr;
|
||||
if (!info || info->type != GAMEOBJECT_TYPE_SPELLCASTER)
|
||||
{
|
||||
error = "spawn must be a static SPELLCASTER GameObject (type 22)";
|
||||
return false;
|
||||
}
|
||||
SpellInfo const* spell = sSpellMgr->GetSpellInfo(info->spellcaster.spell);
|
||||
SpellTargetPosition const* destination = nullptr;
|
||||
if (spell)
|
||||
for (uint8 i = 0; i < MAX_SPELL_EFFECTS; ++i)
|
||||
{
|
||||
auto const& effect = spell->Effects[i];
|
||||
if (effect.Effect != SPELL_EFFECT_TELEPORT_UNITS)
|
||||
continue;
|
||||
// Only explicit database destinations are unambiguous. Homebind,
|
||||
// script-selected destinations and triggered spell chains need
|
||||
// their own interaction definitions, not guessed target nodes.
|
||||
if (destination || (effect.TargetA.GetTarget() != TARGET_DEST_DB &&
|
||||
effect.TargetB.GetTarget() != TARGET_DEST_DB))
|
||||
{
|
||||
error = "portal spell has an ambiguous or non-DB destination";
|
||||
return false;
|
||||
}
|
||||
destination = sSpellMgr->GetSpellTargetPosition(spell->Id, SpellEffIndex(i));
|
||||
if (!destination)
|
||||
{
|
||||
error = "portal teleport effect has no native spell_target_position";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!destination)
|
||||
{
|
||||
error = "portal requires a direct teleport spell with a fixed DB destination";
|
||||
return false;
|
||||
}
|
||||
portal.SpawnId = spawnId;
|
||||
portal.Entry = spawn->id;
|
||||
portal.SpellId = spell->Id;
|
||||
portal.Source.Key = "portal_go_" + std::to_string(spawnId) + "_source";
|
||||
portal.Source.Type = "portal";
|
||||
portal.Source.WorldPointKey = portal.Source.Key;
|
||||
portal.Source.MapId = spawn->mapId;
|
||||
portal.Source.X = spawn->spawnPoint.GetPositionX();
|
||||
portal.Source.Y = spawn->spawnPoint.GetPositionY();
|
||||
portal.Source.Z = spawn->spawnPoint.GetPositionZ();
|
||||
portal.Source.O = spawn->spawnPoint.GetOrientation();
|
||||
portal.Target.Key = "portal_go_" + std::to_string(spawnId) + "_target";
|
||||
portal.Target.Type = "portal";
|
||||
portal.Target.WorldPointKey = portal.Target.Key;
|
||||
portal.Target.MapId = destination->target_mapId;
|
||||
portal.Target.X = destination->target_X;
|
||||
portal.Target.Y = destination->target_Y;
|
||||
portal.Target.Z = destination->target_Z;
|
||||
portal.Target.O = destination->target_Orientation;
|
||||
return true;
|
||||
}
|
||||
|
||||
ClientlessPortalUse const* ClientlessTravelGraphMgr::GetPortalUse(uint32 edgeDatabaseId) const
|
||||
{
|
||||
auto itr = _portalUses.find(edgeDatabaseId);
|
||||
return itr != _portalUses.end() ? &itr->second : nullptr;
|
||||
}
|
||||
|
||||
bool ClientlessTravelGraphMgr::Reload()
|
||||
{
|
||||
_nodes.clear();
|
||||
_edges.clear();
|
||||
_edgePoints.clear();
|
||||
_portalUses.clear();
|
||||
_edgeCount = 0;
|
||||
|
||||
QueryResult nodeResult = WorldDatabase.Query(
|
||||
@@ -65,6 +138,27 @@ bool ClientlessTravelGraphMgr::Reload()
|
||||
while (nodeResult->NextRow());
|
||||
}
|
||||
|
||||
std::unordered_map<uint32, ClientlessPortalUse> portalBindings;
|
||||
// The schema is opt-in, so existing installations retain their graph even
|
||||
// before applying the interactive-portal migration.
|
||||
if (WorldDatabase.Query("SHOW TABLES LIKE 'vcore_clientless_portal_use'"))
|
||||
if (QueryResult bindings = WorldDatabase.Query(
|
||||
"SELECT edge_id,gameobject_spawn_id,gameobject_entry,spell_id FROM vcore_clientless_portal_use"))
|
||||
do
|
||||
{
|
||||
Field* fields = bindings->Fetch();
|
||||
ClientlessPortalUse portal;
|
||||
std::string error;
|
||||
if (!ResolvePortalUse(fields[1].GetUInt32(), portal, error) ||
|
||||
portal.Entry != fields[2].GetUInt32() || portal.SpellId != fields[3].GetUInt32())
|
||||
{
|
||||
TC_LOG_WARN("server.adventurebot.navigation", "PORTAL_USE_BINDING_INVALID edge=%u reason='%s'",
|
||||
fields[0].GetUInt32(), error.c_str());
|
||||
continue;
|
||||
}
|
||||
portalBindings.emplace(fields[0].GetUInt32(), std::move(portal));
|
||||
} while (bindings->NextRow());
|
||||
|
||||
QueryResult edgeResult = WorldDatabase.Query(
|
||||
"SELECT id, from_node_key, to_node_key, edge_type, cost, bidirectional "
|
||||
"FROM vcore_clientless_travel_edge "
|
||||
@@ -100,6 +194,31 @@ bool ClientlessTravelGraphMgr::Reload()
|
||||
if (edge.Cost <= 0.0f)
|
||||
edge.Cost = 1.0f;
|
||||
|
||||
if (edge.Type == "portal_use")
|
||||
{
|
||||
auto binding = portalBindings.find(edge.DatabaseId);
|
||||
auto const* portal = binding != portalBindings.end() ? &binding->second : nullptr;
|
||||
auto const& target = _nodes.at(edge.ToNode);
|
||||
if (!portal || bidirectional || edge.FromNode != portal->Source.Key ||
|
||||
edge.ToNode != portal->Target.Key ||
|
||||
_nodes.at(edge.FromNode).MapId != portal->Source.MapId ||
|
||||
target.MapId != portal->Target.MapId ||
|
||||
std::hypot(std::hypot(target.X - portal->Target.X, target.Y - portal->Target.Y),
|
||||
target.Z - portal->Target.Z) > 1.0f)
|
||||
{
|
||||
TC_LOG_WARN("server.adventurebot.navigation", "PORTAL_USE_EDGE_UNAVAILABLE edge=%u", edge.DatabaseId);
|
||||
continue;
|
||||
}
|
||||
_portalUses.emplace(edge.DatabaseId, *portal);
|
||||
}
|
||||
|
||||
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 +547,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 +594,32 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
|
||||
};
|
||||
std::unordered_map<std::string, VirtualEntry> virtualRoots;
|
||||
|
||||
// Exact road-section cost from recorded arclength; handles reverse
|
||||
// travel as well as virtual entry + exit within the SAME edge.
|
||||
auto roadSectionCost = [&](uint32 edgeId, bool reverse, uint32 begin,
|
||||
uint32 end, float fullCost) -> float
|
||||
{
|
||||
auto itr = _edgePoints.find(edgeId);
|
||||
if (itr == _edgePoints.end() || end >= itr->second.size() || begin > end)
|
||||
return std::numeric_limits<float>::infinity();
|
||||
std::vector<ClientlessTravelEdgePoint> ordered = itr->second;
|
||||
if (reverse)
|
||||
std::reverse(ordered.begin(), ordered.end());
|
||||
float total = 0.0f, section = 0.0f;
|
||||
for (uint32 i = 1; i < ordered.size(); ++i)
|
||||
{
|
||||
float dx = ordered[i].X - ordered[i - 1].X;
|
||||
float dy = ordered[i].Y - ordered[i - 1].Y;
|
||||
float dz = ordered[i].Z - ordered[i - 1].Z;
|
||||
float const segment = std::sqrt(dx * dx + dy * dy + dz * dz);
|
||||
total += segment;
|
||||
if (i > begin && i <= end)
|
||||
section += segment;
|
||||
}
|
||||
return total > 0.1f ? fullCost * section / total
|
||||
: std::numeric_limits<float>::infinity();
|
||||
};
|
||||
|
||||
uint32 startCount = 0;
|
||||
for (auto const& pair : _nodes)
|
||||
{
|
||||
@@ -628,14 +775,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 +840,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 +854,13 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
|
||||
float DistanceSq = 0.0f;
|
||||
ClientlessTravelEdgePoint Point;
|
||||
};
|
||||
float const locationAccessRadius = locationGoal
|
||||
? std::min(300.0f, locationGoal->AnchorRadius) : 0.0f;
|
||||
std::vector<ExitCandidate> rawExitCandidates;
|
||||
std::unordered_map<std::string, std::vector<ExitCandidate>> exitsByFrom;
|
||||
uint32 exitTests = 0, exitAccepted = 0;
|
||||
if (locationGoal)
|
||||
{
|
||||
float const radius = std::min(300.0f, locationGoal->AnchorRadius);
|
||||
float const radius = locationAccessRadius;
|
||||
std::unordered_set<std::string> scannedDirected;
|
||||
for (auto const& edgePair : _edges)
|
||||
{
|
||||
@@ -740,6 +890,8 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
|
||||
if (totalLength < 0.1f)
|
||||
continue;
|
||||
float travelled = 0.0f;
|
||||
ExitCandidate nearest;
|
||||
nearest.DistanceSq = std::numeric_limits<float>::infinity();
|
||||
for (uint32 i = 1; i + 1 < ordered.size(); ++i)
|
||||
{
|
||||
float dx = ordered[i].X - ordered[i - 1].X;
|
||||
@@ -753,7 +905,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 +919,30 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
|
||||
candidate.RoadCost = edge.Cost * (travelled / totalLength);
|
||||
candidate.DistanceSq = dsq;
|
||||
candidate.Point = point;
|
||||
rawExitCandidates.push_back(std::move(candidate));
|
||||
candidate.LocalCost = std::sqrt(dsq);
|
||||
nearest = std::move(candidate);
|
||||
}
|
||||
if (std::isfinite(nearest.DistanceSq))
|
||||
rawExitCandidates.push_back(std::move(nearest));
|
||||
}
|
||||
}
|
||||
std::sort(rawExitCandidates.begin(), rawExitCandidates.end(),
|
||||
[](ExitCandidate const& a, ExitCandidate const& b)
|
||||
{ return a.DistanceSq < b.DistanceSq; });
|
||||
// Bound Detour preflight work, not the number of ground edges.
|
||||
constexpr uint32 MaxExitPreflights = 96;
|
||||
for (ExitCandidate candidate : rawExitCandidates)
|
||||
{
|
||||
if (exitTests >= MaxExitPreflights)
|
||||
break;
|
||||
++exitTests;
|
||||
Position fromPosition, goalPosition;
|
||||
fromPosition.Relocate(candidate.Point.X, candidate.Point.Y,
|
||||
candidate.Point.Z, candidate.Point.O);
|
||||
goalPosition.Relocate(locationGoal->X, locationGoal->Y,
|
||||
locationGoal->Z, 0.0f);
|
||||
if (candidate.DistanceSq <= 2.25f)
|
||||
candidate.LocalCost = 0.0f;
|
||||
else
|
||||
{
|
||||
VcorePositionPathResult const probe = PathGenerator::AnalyzePositions(
|
||||
locationGoal->MapId, locationGoal->MapId,
|
||||
player->GetInstanceId(), fromPosition, goalPosition, NAV_GROUND);
|
||||
if (!probe.IsComplete() || probe.FinalDistance > 1.5f ||
|
||||
!std::isfinite(probe.PathLength) || probe.PathLength > 500.0f)
|
||||
continue;
|
||||
candidate.LocalCost = probe.PathLength;
|
||||
}
|
||||
exitsByFrom[candidate.FromNode].push_back(std::move(candidate));
|
||||
++exitAccepted;
|
||||
}
|
||||
for (ExitCandidate const& candidate : rawExitCandidates)
|
||||
exitsByFrom[candidate.FromNode].push_back(candidate);
|
||||
TC_LOG_INFO("server.adventurebot.navigation",
|
||||
"C820_EXIT_PREFLIGHT candidates=%u tested=%u accepted=%u radius=%.1f",
|
||||
uint32(rawExitCandidates.size()), exitTests, exitAccepted, radius);
|
||||
"C821_EXIT_CANDIDATES directedEdges=%u radius=%.1f localCost=air-distance validation=on-arrival",
|
||||
uint32(rawExitCandidates.size()), radius);
|
||||
}
|
||||
// Exact road-section cost from recorded arclength; handles reverse
|
||||
// travel as well as virtual entry + exit within the SAME edge.
|
||||
auto roadSectionCost = [&](uint32 edgeId, bool reverse, uint32 begin,
|
||||
uint32 end, float fullCost) -> float
|
||||
{
|
||||
auto itr = _edgePoints.find(edgeId);
|
||||
if (itr == _edgePoints.end() || end >= itr->second.size() || begin > end)
|
||||
return std::numeric_limits<float>::infinity();
|
||||
std::vector<ClientlessTravelEdgePoint> ordered = itr->second;
|
||||
if (reverse)
|
||||
std::reverse(ordered.begin(), ordered.end());
|
||||
float total = 0.0f, section = 0.0f;
|
||||
for (uint32 i = 1; i < ordered.size(); ++i)
|
||||
{
|
||||
float dx = ordered[i].X - ordered[i - 1].X;
|
||||
float dy = ordered[i].Y - ordered[i - 1].Y;
|
||||
float dz = ordered[i].Z - ordered[i - 1].Z;
|
||||
float const segment = std::sqrt(dx * dx + dy * dy + dz * dz);
|
||||
total += segment;
|
||||
if (i > begin && i <= end)
|
||||
section += segment;
|
||||
}
|
||||
return total > 0.1f ? fullCost * section / total
|
||||
: std::numeric_limits<float>::infinity();
|
||||
};
|
||||
struct ExitSelection
|
||||
{
|
||||
bool Active = false;
|
||||
bool RootPartial = false;
|
||||
ExitCandidate Candidate;
|
||||
bool ForwardPartial = false;
|
||||
uint32 StartIndex = 0;
|
||||
ClientlessTravelEdgePoint EntryPoint;
|
||||
} bestExit;
|
||||
float bestGoalCost = std::numeric_limits<float>::infinity();
|
||||
std::string bestGoalState;
|
||||
@@ -915,23 +1023,15 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
|
||||
float dy = candidateNode.Y - locationGoal->Y;
|
||||
float dz = candidateNode.Z - locationGoal->Z;
|
||||
float const direct = std::sqrt(dx*dx + dy*dy + dz*dz);
|
||||
if (direct <= locationGoal->AnchorRadius)
|
||||
// Use the same local access limit for nodes and road exits.
|
||||
// Otherwise a distant node can bypass a closer usable exit.
|
||||
if (direct <= locationAccessRadius)
|
||||
{
|
||||
Position a, b;
|
||||
a.Relocate(candidateNode.X, candidateNode.Y, candidateNode.Z, candidateNode.O);
|
||||
b.Relocate(locationGoal->X, locationGoal->Y, locationGoal->Z, 0.0f);
|
||||
float finalCost = std::numeric_limits<float>::infinity();
|
||||
if (direct <= 1.5f)
|
||||
finalCost = 0.0f;
|
||||
else
|
||||
{
|
||||
VcorePositionPathResult const probe = PathGenerator::AnalyzePositions(
|
||||
candidateNode.MapId, candidateNode.MapId,
|
||||
player->GetInstanceId(), a, b, NAV_GROUND);
|
||||
if (probe.IsComplete() && probe.FinalDistance <= 1.5f &&
|
||||
std::isfinite(probe.PathLength) && probe.PathLength <= 500.0f)
|
||||
finalCost = probe.PathLength;
|
||||
}
|
||||
// Node anchors and virtual exits use the same estimate.
|
||||
// Neither is rejected because a remote tile is unloaded.
|
||||
std::string const finishKey = "finish:" + candidateNode.Key;
|
||||
float const finalCost = blockedBridgeKeys && blockedBridgeKeys->count(finishKey)
|
||||
? std::numeric_limits<float>::infinity() : direct;
|
||||
if (current.Cost + finalCost < bestGoalCost)
|
||||
{
|
||||
bestGoalCost = current.Cost + finalCost;
|
||||
@@ -1081,13 +1181,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 +1539,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";
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
* The graph is deliberately data driven and may be incomplete. Missing routes
|
||||
* fail cleanly. New nodes and edges can be added without rebuilding the core.
|
||||
*
|
||||
* C8.6 executes only "ground" edges. Other edge types may already be stored
|
||||
* and planned, but execution stops cleanly until their executor exists.
|
||||
* Ground, taxi, AreaTrigger portals, interactive object portals and native
|
||||
* transport edges have dedicated executors.
|
||||
*/
|
||||
#ifndef VCORE_CLIENTLESS_TRAVEL_GRAPH_MGR_H
|
||||
#define VCORE_CLIENTLESS_TRAVEL_GRAPH_MGR_H
|
||||
@@ -39,6 +39,17 @@ struct TC_GAME_API ClientlessTravelNode
|
||||
float O = 0.0f;
|
||||
};
|
||||
|
||||
// A fixed-destination, clickable SPELLCASTER GameObject. The destination is
|
||||
// derived from the native spell data; this is never a synthetic teleport.
|
||||
struct TC_GAME_API ClientlessPortalUse
|
||||
{
|
||||
uint32 SpawnId = 0;
|
||||
uint32 Entry = 0;
|
||||
uint32 SpellId = 0;
|
||||
ClientlessTravelNode Source;
|
||||
ClientlessTravelNode Target;
|
||||
};
|
||||
|
||||
struct TC_GAME_API ClientlessTravelEdge
|
||||
{
|
||||
uint32 DatabaseId = 0;
|
||||
@@ -151,6 +162,9 @@ public:
|
||||
|
||||
std::vector<ClientlessTravelEdgePoint> const* GetEdgePoints(uint32 edgeDatabaseId) const;
|
||||
|
||||
static bool ResolvePortalUse(uint32 spawnId, ClientlessPortalUse& portal, std::string& error);
|
||||
ClientlessPortalUse const* GetPortalUse(uint32 edgeDatabaseId) const;
|
||||
|
||||
private:
|
||||
std::string FindNearestStartNode(Player const* player, float maxDistance) const;
|
||||
|
||||
@@ -160,6 +174,7 @@ private:
|
||||
std::unordered_map<std::string, ClientlessTravelNode> _nodes;
|
||||
std::unordered_map<std::string, std::vector<ClientlessTravelEdge>> _edges;
|
||||
std::unordered_map<uint32, std::vector<ClientlessTravelEdgePoint>> _edgePoints;
|
||||
std::unordered_map<uint32, ClientlessPortalUse> _portalUses;
|
||||
};
|
||||
|
||||
#define sClientlessTravelGraphMgr ClientlessTravelGraphMgr::instance()
|
||||
|
||||
@@ -3414,6 +3414,7 @@ void World::UpdateSessions(uint32 diff)
|
||||
continue;
|
||||
}
|
||||
|
||||
session->CompleteClientlessTeleport();
|
||||
WorldSessionFilter updater(session);
|
||||
if (!session->Update(diff, updater))
|
||||
{
|
||||
|
||||
@@ -1,18 +1,3 @@
|
||||
-- Arthoran completely overrides generic.lua.
|
||||
log("Arthoran C8.13b bridge test loaded")
|
||||
|
||||
--queue_action("approach", {
|
||||
-- capability = "vendor",
|
||||
-- max_distance = 40,
|
||||
-- approach_distance = 2
|
||||
--})
|
||||
|
||||
queue_action("travel_to_location", {
|
||||
id = "the_gilded_rose"
|
||||
})
|
||||
|
||||
--queue_action("travel_to_node", {
|
||||
-- node = "lakeshire",
|
||||
-- bridge_radius = 400,
|
||||
-- bridge_path_max = 600
|
||||
--})
|
||||
-- Native sea-route test: Stormwind -> Ruttheran, no onward action.
|
||||
log("SHIP_TEST_SW_RUTTHERAN loaded")
|
||||
queue_action("travel_to_node", {node="ship_ruttheran_br", hard_destination=true})
|
||||
@@ -12,6 +12,7 @@
|
||||
#include "ScriptMgr.h"
|
||||
#include "Chat.h"
|
||||
#include "ClientlessPlayerMgr.h"
|
||||
#include "ClientlessTransportMgr.h"
|
||||
#include "ClientlessTravelMgr.h"
|
||||
#include "ClientlessPerceptionMgr.h"
|
||||
#include "ClientlessActionExecutorMgr.h"
|
||||
@@ -662,6 +663,23 @@ namespace VianoBotCommands
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool HandleClientlessLuaReload(ChatHandler* handler, char const* args)
|
||||
{
|
||||
uint32 guid = 0;
|
||||
if (!ParseCharacterGuid(handler, args, guid))
|
||||
{
|
||||
handler->SendSysMessage("Usage: .clientless luareload <characterGuid>");
|
||||
return false;
|
||||
}
|
||||
WorldSession* session = sWorld->FindClientlessSession(guid);
|
||||
Player* player = session ? session->GetPlayer() : nullptr;
|
||||
std::string detail;
|
||||
bool success = sClientlessLuaEngine->ReloadAdventureScript(player, detail);
|
||||
handler->PSendSysMessage("Clientless Lua reload GUID %u: %s %s Script root: %s",
|
||||
guid, success ? "OK." : "FAILED.", detail.c_str(), sClientlessLuaEngine->GetScriptRoot().c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
// CLIENTLESS_PLAYER_CORE_C8_1_POINT_NAVIGATION_TEST
|
||||
static bool HandleClientlessTravelTo(ChatHandler* handler, char const* args)
|
||||
{
|
||||
@@ -1141,7 +1159,7 @@ static bool HandleClientlessTaxiImport(ChatHandler* handler, char const* /*args*
|
||||
WorldDatabase.DirectPExecute(
|
||||
"UPDATE vcore_clientless_world_point "
|
||||
"SET category='travel_taxi',map=%u,position_x=%.6f,position_y=%.6f,position_z=%.6f,"
|
||||
"orientation=0,enabled=1,comment='Auto-imported from TaxiNodes.dbc node %u' "
|
||||
"orientation=0,comment=IF(enabled=1,'Auto-imported from TaxiNodes.dbc node %u',comment) "
|
||||
"WHERE point_key='%s'",
|
||||
uint32(taxiNode->ContinentID),
|
||||
taxiNode->Pos.X,
|
||||
@@ -1175,8 +1193,8 @@ static bool HandleClientlessTaxiImport(ChatHandler* handler, char const* /*args*
|
||||
{
|
||||
WorldDatabase.DirectPExecute(
|
||||
"UPDATE vcore_clientless_travel_node "
|
||||
"SET node_type='taxi',world_point_key='%s',enabled=1,"
|
||||
"comment='Auto-imported TaxiNodes.dbc node %u' "
|
||||
"SET node_type='taxi',world_point_key='%s',"
|
||||
"comment=IF(enabled=1,'Auto-imported TaxiNodes.dbc node %u',comment) "
|
||||
"WHERE node_key='%s'",
|
||||
key.c_str(), taxiNodeId, key.c_str());
|
||||
++updatedNodes;
|
||||
@@ -1313,8 +1331,88 @@ static bool HandleClientlessTaxiInfo(ChatHandler* handler, char const* args)
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool HandleClientlessPortalImport(ChatHandler* handler, char const* /*args*/)
|
||||
static bool HandleClientlessPortalObjectImport(ChatHandler* handler, char const* args)
|
||||
{
|
||||
std::istringstream input(args ? args : "");
|
||||
uint32 spawnId = 0;
|
||||
std::string extra;
|
||||
if (!(input >> spawnId) || !spawnId || (input >> extra))
|
||||
{
|
||||
handler->SendSysMessage("Usage: .clientless portal object import <gameobjectSpawnId>");
|
||||
return false;
|
||||
}
|
||||
if (!WorldDatabase.Query("SHOW TABLES LIKE 'vcore_clientless_portal_use'"))
|
||||
{
|
||||
handler->SendSysMessage("Apply sql/custom/world/2026_10_26_02_clientless_portal_use.sql to the test world DB first.");
|
||||
return false;
|
||||
}
|
||||
ClientlessPortalUse portal;
|
||||
std::string error;
|
||||
if (!ClientlessTravelGraphMgr::ResolvePortalUse(spawnId, portal, error))
|
||||
{
|
||||
handler->PSendSysMessage("Portal object import rejected: %s.", error.c_str());
|
||||
return false;
|
||||
}
|
||||
// Seed only. An explicit node move or enabled=0 decision must survive a
|
||||
// repeated import; native target drift is rejected when loading the graph.
|
||||
WorldDatabaseTransaction transaction = WorldDatabase.BeginTransaction();
|
||||
for (auto const* node : { &portal.Source, &portal.Target })
|
||||
{
|
||||
transaction->PAppend(
|
||||
"INSERT IGNORE INTO vcore_clientless_world_point "
|
||||
"(point_key,category,map,position_x,position_y,position_z,orientation,enabled,comment) "
|
||||
"VALUES ('%s','travel_portal',%u,%.6f,%.6f,%.6f,%.6f,1,'Interactive portal spawn %u')",
|
||||
node->Key.c_str(), node->MapId, node->X, node->Y, node->Z, node->O, spawnId);
|
||||
transaction->PAppend(
|
||||
"INSERT IGNORE INTO vcore_clientless_travel_node "
|
||||
"(node_key,node_type,world_point_key,enabled,comment) "
|
||||
"VALUES ('%s','portal','%s',1,'Interactive portal spawn %u')",
|
||||
node->Key.c_str(), node->WorldPointKey.c_str(), spawnId);
|
||||
}
|
||||
transaction->PAppend(
|
||||
"INSERT IGNORE INTO vcore_clientless_travel_edge "
|
||||
"(from_node_key,to_node_key,edge_type,cost,bidirectional,enabled,comment) "
|
||||
"VALUES ('%s','%s','portal_use',1,0,1,'Native GameObject portal spawn %u')",
|
||||
portal.Source.Key.c_str(), portal.Target.Key.c_str(), spawnId);
|
||||
transaction->PAppend(
|
||||
"INSERT IGNORE INTO vcore_clientless_portal_use "
|
||||
"(edge_id,gameobject_spawn_id,gameobject_entry,spell_id) "
|
||||
"SELECT id,%u,%u,%u FROM vcore_clientless_travel_edge "
|
||||
"WHERE from_node_key='%s' AND to_node_key='%s' AND edge_type='portal_use'",
|
||||
spawnId, portal.Entry, portal.SpellId, portal.Source.Key.c_str(), portal.Target.Key.c_str());
|
||||
WorldDatabase.DirectCommitTransaction(transaction);
|
||||
sClientlessTravelGraphMgr->Reload();
|
||||
sClientlessWorldPointMgr->Reload();
|
||||
QueryResult registered = WorldDatabase.PQuery(
|
||||
"SELECT id FROM vcore_clientless_travel_edge WHERE from_node_key='%s' "
|
||||
"AND to_node_key='%s' AND edge_type='portal_use'", portal.Source.Key.c_str(), portal.Target.Key.c_str());
|
||||
if (!registered || !sClientlessTravelGraphMgr->GetPortalUse(registered->Fetch()[0].GetUInt32()))
|
||||
{
|
||||
handler->SendSysMessage("Portal object not active in graph. Check database errors, existing enabled flags and reload rejection logs; existing data was not overwritten.");
|
||||
return true;
|
||||
}
|
||||
handler->PSendSysMessage(
|
||||
"Portal object import complete: spawn=%u entry=%u spell=%u %s -> %s (map %u -> %u). "
|
||||
"One-way native click interaction. Existing coordinates and enabled flags preserved; inspect reload logs for rejected edges.",
|
||||
spawnId, portal.Entry, portal.SpellId, portal.Source.Key.c_str(), portal.Target.Key.c_str(),
|
||||
portal.Source.MapId, portal.Target.MapId);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool HandleClientlessPortalImport(ChatHandler* handler, char const* args)
|
||||
{
|
||||
uint32 requested = 0;
|
||||
std::istringstream input(args ? args : "");
|
||||
input >> std::ws;
|
||||
if (input.peek() != std::char_traits<char>::eof())
|
||||
{
|
||||
std::string extra;
|
||||
if (!(input >> requested) || !requested || (input >> extra))
|
||||
{
|
||||
handler->SendSysMessage("Usage: .clientless portal import [triggerId]");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
uint32 imported = 0;
|
||||
uint32 missingDbc = 0;
|
||||
uint32 skippedSameMap = 0;
|
||||
@@ -1340,6 +1438,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 +1453,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 +1561,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 +1615,7 @@ static bool HandleClientlessPortalInfo(ChatHandler* handler, char const* args)
|
||||
" target map=%u pos=(%.2f %.2f %.2f o=%.2f) keys=portal_at_%u_source -> portal_at_%u_target%s",
|
||||
targetMap, fields[2].GetFloat(), fields[3].GetFloat(), fields[4].GetFloat(), fields[5].GetFloat(),
|
||||
triggerId, triggerId,
|
||||
uint32(source->ContinentID) == targetMap ? " [same-map: not imported by C8.13a]" : "");
|
||||
uint32(source->ContinentID) == targetMap ? " [same-map: use targeted import <triggerId>]" : "");
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -2104,6 +2202,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 +2232,17 @@ static bool HandleClientlessTravelReload(ChatHandler* handler, char const* /*arg
|
||||
|
||||
static std::vector<ChatCommand> clientlessCommands =
|
||||
{
|
||||
{ "login", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessLogin, "" },
|
||||
{ "logout", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessLogout, "" },
|
||||
{ "info", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessInfo, "" },
|
||||
{ "luareload", rbac::RBAC_PERM_COMMAND_GM, true, &HandleClientlessLuaReload, "" },
|
||||
{ "transport", rbac::RBAC_PERM_COMMAND_GM, true, &HandleClientlessTransport, "" },
|
||||
{ "login", rbac::RBAC_PERM_COMMAND_GM, true, &HandleClientlessLogin, "" },
|
||||
{ "logout", rbac::RBAC_PERM_COMMAND_GM, true, &HandleClientlessLogout, "" },
|
||||
{ "info", rbac::RBAC_PERM_COMMAND_GM, true, &HandleClientlessInfo, "" },
|
||||
{ "travelto", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessTravelTo, "" },
|
||||
{ "travelinfo", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessTravelInfo, "" },
|
||||
{ "travelinfo", rbac::RBAC_PERM_COMMAND_GM, true, &HandleClientlessTravelInfo, "" },
|
||||
{ "navtest", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessNavTest, "" },
|
||||
{ "navinfo", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessNavInfo, "" },
|
||||
{ "navinfo", rbac::RBAC_PERM_COMMAND_GM, true, &HandleClientlessNavInfo, "" },
|
||||
{ "perceive", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessPerceive, "" },
|
||||
{ "actioninfo", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessActionInfo, "" },
|
||||
{ "actioninfo", rbac::RBAC_PERM_COMMAND_GM, true, &HandleClientlessActionInfo, "" },
|
||||
{ "pointreload", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessPointReload, "" },
|
||||
{ "location", rbac::RBAC_PERM_COMMAND_GM, false, nullptr, "", {
|
||||
{ "add", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessLocationAdd, "" },
|
||||
@@ -2169,9 +2274,12 @@ 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, "", {
|
||||
{ "object", rbac::RBAC_PERM_COMMAND_GM, true, nullptr, "", {
|
||||
{ "import", rbac::RBAC_PERM_COMMAND_GM, true, &HandleClientlessPortalObjectImport, "" },
|
||||
} },
|
||||
{ "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 +2287,7 @@ static bool HandleClientlessTravelReload(ChatHandler* handler, char const* /*arg
|
||||
static std::vector<ChatCommand> commandTable =
|
||||
{
|
||||
{ "viano", rbac::RBAC_PERM_COMMAND_GM, false, nullptr, "", vianoCommands },
|
||||
{ "clientless", rbac::RBAC_PERM_COMMAND_GM, false, nullptr, "", clientlessCommands },
|
||||
{ "clientless", rbac::RBAC_PERM_COMMAND_GM, true, nullptr, "", clientlessCommands },
|
||||
};
|
||||
|
||||
return commandTable;
|
||||
|
||||
@@ -68,6 +68,15 @@
|
||||
RealmID = 1
|
||||
|
||||
#
|
||||
# Clientless.ScriptRoot
|
||||
# Description: Lua root read directly on login and .clientless luareload <GUID>.
|
||||
# Set an absolute source directory for development without installing scripts.
|
||||
# VCORE_CLIENTLESS_SCRIPT_DIR overrides this setting if nonempty.
|
||||
# Restart once after changing the root; Lua edits require no restart.
|
||||
# Default: "scripts/clientless" (relative to the worldserver working directory)
|
||||
|
||||
Clientless.ScriptRoot = "scripts/clientless"
|
||||
|
||||
# DataDir
|
||||
# Description: Data directory setting.
|
||||
# Important: DataDir needs to be quoted, as the string might contain space characters.
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Compile the production reload/bindings with real Lua and deterministic server doubles."""
|
||||
import pathlib
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||||
SOURCE = (ROOT / "src/server/game/World/ClientlessLuaEngine.cpp").read_text()
|
||||
|
||||
|
||||
def function(signature):
|
||||
start = SOURCE.index(signature)
|
||||
opening = SOURCE.index("{", start)
|
||||
depth = 1
|
||||
end = opening + 1
|
||||
while depth:
|
||||
depth += (SOURCE[end] == "{") - (SOURCE[end] == "}")
|
||||
end += 1
|
||||
return SOURCE[start:end]
|
||||
|
||||
|
||||
HARNESS = r'''
|
||||
#include <lua.hpp>
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
using uint32 = uint32_t;
|
||||
#define TC_LOG_INFO(...) ((void)0)
|
||||
struct ClientlessIntent {
|
||||
std::string Name;
|
||||
std::unordered_map<std::string,std::string> Parameters;
|
||||
bool Empty() const { return Name.empty(); }
|
||||
};
|
||||
using ClientlessAction = ClientlessIntent;
|
||||
struct ClientlessAdventureProfile { int Revision = 0; };
|
||||
struct Guid { uint32 GetCounter() const { return 26; } };
|
||||
struct Player {
|
||||
bool aboard = false, flying = false, teleport = false;
|
||||
Guid GetGUID() { return {}; }
|
||||
bool IsInWorld() { return true; }
|
||||
bool GetTransport() { return aboard; }
|
||||
bool IsInFlight() { return flying; }
|
||||
bool IsBeingTeleported() { return teleport; }
|
||||
};
|
||||
enum class ClientlessActionExecutionState { Idle, SelectingTarget, Moving, Completed, Failed };
|
||||
enum class ClientlessTravelState { Idle, Moving, Arrived };
|
||||
struct ActionStatus { ClientlessActionExecutionState State = ClientlessActionExecutionState::Idle; };
|
||||
struct TravelStatus { ClientlessTravelState State = ClientlessTravelState::Idle; };
|
||||
struct Manager {
|
||||
bool managed = true;
|
||||
std::vector<ClientlessAction> actions;
|
||||
ClientlessIntent intent;
|
||||
bool IsManaged(uint32) { return managed; }
|
||||
unsigned GetQueuedActionCount(uint32) { return actions.size(); }
|
||||
bool QueueAction(uint32, ClientlessAction a) { actions.push_back(std::move(a)); return true; }
|
||||
bool SetIntent(uint32, ClientlessIntent i) { intent = std::move(i); return true; }
|
||||
bool ClearIntent(uint32) { intent = {}; return true; }
|
||||
} manager;
|
||||
struct Executor { ActionStatus status; ActionStatus* GetStatus(uint32) { return &status; } } executor;
|
||||
struct Travel { TravelStatus status; TravelStatus* GetStatus(uint32) { return &status; } } travel;
|
||||
auto* sClientlessPlayerMgr = &manager;
|
||||
auto* sClientlessActionExecutorMgr = &executor;
|
||||
auto* sClientlessTravelMgr = &travel;
|
||||
class ClientlessLuaEngine {
|
||||
public:
|
||||
std::unordered_map<uint32,ClientlessAdventureProfile> _profiles;
|
||||
std::string _scriptRoot = "/test";
|
||||
std::string script;
|
||||
bool profileOK = true;
|
||||
bool LoadAdventureProfile(Player*) {
|
||||
if (!profileOK) return false;
|
||||
_profiles[26].Revision++;
|
||||
return true;
|
||||
}
|
||||
bool ExecuteAdventureScript(Player*);
|
||||
bool ReloadAdventureScript(Player*, std::string&);
|
||||
};
|
||||
'''
|
||||
HARNESS += SOURCE[SOURCE.index("struct ReloadEffects"):SOURCE.index("uint32 BoundGuid")]
|
||||
for signature in ["uint32 BoundGuid", "std::unordered_map<std::string, std::string> ReadMap",
|
||||
"int LuaIntend", "int LuaClearIntent", "int LuaQueueAction"]:
|
||||
HARNESS += function(signature) + "\n"
|
||||
HARNESS += r'''
|
||||
bool ClientlessLuaEngine::ExecuteAdventureScript(Player*) {
|
||||
lua_State* L = luaL_newstate();
|
||||
luaL_openlibs(L);
|
||||
for (auto entry : {std::pair{"intend", LuaIntend}, {"clear_intent", LuaClearIntent}, {"queue_action", LuaQueueAction}}) {
|
||||
lua_pushinteger(L,26); lua_pushcclosure(L,entry.second,1); lua_setglobal(L,entry.first);
|
||||
}
|
||||
bool success = luaL_loadstring(L, script.c_str()) == LUA_OK && lua_pcall(L,0,0,0) == LUA_OK;
|
||||
lua_close(L);
|
||||
return success;
|
||||
}
|
||||
'''
|
||||
HARNESS += function("bool ClientlessLuaEngine::ReloadAdventureScript")
|
||||
HARNESS += r'''
|
||||
int main() {
|
||||
Player p;
|
||||
ClientlessLuaEngine engine;
|
||||
std::string detail;
|
||||
engine._profiles[26].Revision = 4;
|
||||
manager.intent.Name = "old";
|
||||
engine.script = "queue_action('move', {node='tram_if'}); intend('new'); error('failure')";
|
||||
assert(!engine.ReloadAdventureScript(&p,detail));
|
||||
assert(manager.actions.empty() && manager.intent.Name == "old");
|
||||
assert(engine._profiles.at(26).Revision == 4 && !pendingReload);
|
||||
engine.script = "this is invalid lua";
|
||||
assert(!engine.ReloadAdventureScript(&p,detail));
|
||||
assert(engine._profiles.at(26).Revision == 4 && manager.actions.empty());
|
||||
engine.profileOK = false;
|
||||
assert(!engine.ReloadAdventureScript(&p,detail));
|
||||
engine.profileOK = true;
|
||||
engine.script = "assert(not queue_action('')); queue_action('move', {node='tram_if'}); clear_intent(); intend('new')";
|
||||
assert(engine.ReloadAdventureScript(&p,detail));
|
||||
assert(manager.actions.size() == 1 && manager.actions[0].Parameters.at("node") == "tram_if");
|
||||
assert(manager.intent.Name == "new" && engine._profiles.at(26).Revision == 5);
|
||||
assert(!engine.ReloadAdventureScript(&p,detail)); // queued actions must not duplicate
|
||||
manager.actions.clear();
|
||||
executor.status.State = ClientlessActionExecutionState::Moving;
|
||||
assert(!engine.ReloadAdventureScript(&p,detail));
|
||||
executor.status.State = ClientlessActionExecutionState::Completed;
|
||||
travel.status.State = ClientlessTravelState::Moving;
|
||||
assert(!engine.ReloadAdventureScript(&p,detail));
|
||||
travel.status.State = ClientlessTravelState::Arrived;
|
||||
p.aboard = true; assert(!engine.ReloadAdventureScript(&p,detail)); p.aboard = false;
|
||||
p.flying = true; assert(!engine.ReloadAdventureScript(&p,detail)); p.flying = false;
|
||||
p.teleport = true; assert(!engine.ReloadAdventureScript(&p,detail)); p.teleport = false;
|
||||
assert(!engine.ReloadAdventureScript(nullptr,detail));
|
||||
manager.managed = false; assert(!engine.ReloadAdventureScript(&p,detail)); manager.managed = true;
|
||||
engine._profiles.clear(); engine.script = "error('failure')";
|
||||
assert(!engine.ReloadAdventureScript(&p,detail) && engine._profiles.empty());
|
||||
engine.script = "clear_intent()";
|
||||
assert(engine.ReloadAdventureScript(&p,detail) && manager.intent.Empty());
|
||||
}
|
||||
'''
|
||||
with tempfile.TemporaryDirectory(prefix="vcore-lua-reload-") as folder:
|
||||
source = pathlib.Path(folder) / "test.cpp"
|
||||
binary = pathlib.Path(folder) / "test"
|
||||
source.write_text(HARNESS)
|
||||
flags = subprocess.check_output(["pkg-config", "--cflags", "--libs", "lua5.4"], text=True).split()
|
||||
subprocess.run(["c++", "-std=c++17", str(source), "-o", str(binary), *flags], check=True)
|
||||
subprocess.run([str(binary)], check=True)
|
||||
print("Lua reload regression tests passed (production reload/bindings, real Lua 5.4).")
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Exercise the actual portal handler/ACK/destination checks with Core doubles."""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
source = (ROOT / "src/server/game/World/ClientlessActionExecutorMgr.cpp").read_text()
|
||||
start = source.index(" WorldPacket areaTriggerPacket(CMSG_AREATRIGGER, 4);")
|
||||
end = source.index(" record.Status.State = ClientlessActionExecutionState::Moving;", start)
|
||||
fragment = source[start:end]
|
||||
assert "same-map portal transition is not supported" not in source
|
||||
|
||||
harness = r'''
|
||||
#include <cassert>
|
||||
#include <cmath>
|
||||
#include <string>
|
||||
#define TC_LOG_WARN(...) ((void)0)
|
||||
enum class ClientlessActionExecutionState { Idle, Failed, Moving };
|
||||
constexpr int CMSG_AREATRIGGER=1;
|
||||
struct WorldPacket { WorldPacket(int,int){} void operator<<(int){} };
|
||||
struct Player;
|
||||
struct Session {
|
||||
Player* p;
|
||||
int accepted=0,ackCount=0;
|
||||
bool clientless=true,ackWorks=true;
|
||||
void HandleAreaTriggerOpcode(WorldPacket&);
|
||||
void CompleteClientlessTeleport();
|
||||
bool IsClientless() { return clientless; }
|
||||
};
|
||||
struct Player {
|
||||
int map=1; float x=8393,y=1003,z=30;
|
||||
bool pending=false,world=true;
|
||||
Session session{this};
|
||||
bool IsBeingTeleported() { return pending; }
|
||||
bool IsBeingTeleportedFar() { return pending&&map==0; }
|
||||
bool IsInWorld() { return world; }
|
||||
int GetMapId() { return map; }
|
||||
float GetPositionX() { return x; } float GetPositionY() { return y; } float GetPositionZ() { return z; }
|
||||
Session* GetSession() { return &session; }
|
||||
};
|
||||
struct Teleport { int target_mapId=1; float target_X=9946,target_Y=2612,target_Z=1316; } destination;
|
||||
void Session::HandleAreaTriggerOpcode(WorldPacket&) { if (accepted) p->pending=true; }
|
||||
void Session::CompleteClientlessTeleport() {
|
||||
++ackCount;
|
||||
if (!ackWorks) return;
|
||||
p->pending=false;p->map=destination.target_mapId;
|
||||
p->x=destination.target_X;p->y=destination.target_Y;p->z=destination.target_Z;
|
||||
}
|
||||
struct Record {
|
||||
struct {ClientlessActionExecutionState State=ClientlessActionExecutionState::Idle; std::string Detail,TargetName;} Status;
|
||||
bool Active=true;
|
||||
} record;
|
||||
struct {int EdgeDatabaseId=1;std::string NodeKey="portal_at_542_target";} step;
|
||||
bool transfer(Player* player) {
|
||||
int triggerId=542,characterGuid=26;
|
||||
auto teleport=&destination;
|
||||
'''
|
||||
harness += fragment
|
||||
harness += r'''
|
||||
return true;
|
||||
}
|
||||
int main() {
|
||||
Player denied;
|
||||
assert(!transfer(&denied)&&!record.Active&&record.Status.State==ClientlessActionExecutionState::Failed);
|
||||
Player near;near.session.accepted=1;record={};
|
||||
assert(transfer(&near)&&near.session.ackCount==1&&near.map==1&&near.x==9946&&!near.pending);
|
||||
Player far;far.map=0;far.session.accepted=1;record={};
|
||||
assert(transfer(&far)&&far.session.ackCount==1&&far.map==1&&!far.pending);
|
||||
Player pending;pending.session.accepted=1;pending.session.ackWorks=false;record={};
|
||||
assert(!transfer(&pending)&&pending.pending&&!record.Active);
|
||||
Player network;network.session.accepted=1;network.session.clientless=false;record={};
|
||||
assert(!transfer(&network)&&!record.Active);
|
||||
Player wrongMap;wrongMap.map=0;record={};assert(!transfer(&wrongMap));
|
||||
Player alreadyAtTarget;alreadyAtTarget.x=destination.target_X;alreadyAtTarget.y=destination.target_Y;alreadyAtTarget.z=destination.target_Z;
|
||||
record={};assert(transfer(&alreadyAtTarget)); // destination reached independently is valid
|
||||
}
|
||||
'''
|
||||
with tempfile.TemporaryDirectory(prefix="vcore-portal-test-") as folder:
|
||||
cpp, binary = Path(folder) / "test.cpp", Path(folder) / "test"
|
||||
cpp.write_text(harness)
|
||||
subprocess.run(["c++", "-std=c++17", str(cpp), "-o", str(binary)], check=True)
|
||||
subprocess.run([str(binary)], check=True)
|
||||
print("Portal near/far ACK and denied same-map transfer regressions passed.")
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Compile the production object-portal start/update methods against 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()
|
||||
a = source.index('bool ClientlessActionExecutorMgr::StartPortalUse(')
|
||||
b = source.index('bool ClientlessActionExecutorMgr::StartRecordedEdgePath(', a)
|
||||
methods = source[a:b]
|
||||
harness = r'''
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
#include <cmath>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
using uint32=uint32_t; using uint64=uint64_t;
|
||||
#define TC_LOG_INFO(...) ((void)0)
|
||||
#define TC_LOG_WARN(...) ((void)0)
|
||||
constexpr int GAMEOBJECT_FLAGS=0, GO_FLAG_NOT_SELECTABLE=16, GO_FLAG_LOCKED=2;
|
||||
constexpr int GAMEOBJECT_TYPE_SPELLCASTER=22, CMSG_GAMEOBJ_USE=1;
|
||||
struct WorldPacket { int guid=0; WorldPacket(int,int){} void operator<<(int v){guid=v;} };
|
||||
struct GameObject {
|
||||
int guid=83,entry=183325,flags=0,type=22;
|
||||
bool spawned=true;
|
||||
struct {struct {int spell=17334;} spellcaster;} info;
|
||||
int GetEntry(){return entry;} int GetGUID(){return guid;}
|
||||
bool isSpawned(){return spawned;} bool HasFlag(int,int f){return flags&f;}
|
||||
int GetGoType(){return type;} auto GetGOInfo(){return &info;}
|
||||
};
|
||||
struct Map {
|
||||
std::multimap<uint32,GameObject*> objects;
|
||||
auto const& GetGameObjectBySpawnIdStore(){return objects;}
|
||||
};
|
||||
struct Player;
|
||||
struct Session {
|
||||
Player* player;
|
||||
bool clientless=true,accept=true,ackWorks=true,far=true,wrongTarget=false;
|
||||
int clicks=0,acks=0; uint64 sequence=0;
|
||||
Player* GetPlayer(){return player;}
|
||||
bool IsClientless(){return clientless;}
|
||||
uint64 GetClientlessTeleportSequence(){return sequence;}
|
||||
void HandleGameObjectUseOpcode(WorldPacket&);
|
||||
void CompleteClientlessTeleport();
|
||||
};
|
||||
using WorldSession=Session;
|
||||
struct Player {
|
||||
int mapId=530;float x=-1894,y=5387,z=-12;
|
||||
bool alive=true,pending=false,world=true,phase=true,visible=true,inRange=true,aboard=false;
|
||||
Map map;Session session{this};GameObject go;
|
||||
Player(){map.objects.emplace(83,&go);}
|
||||
int GetMapId(){return mapId;}bool IsAlive(){return alive;}
|
||||
bool IsBeingTeleported(){return pending;}bool IsInWorld(){return world;}
|
||||
void* GetTransport(){return aboard?this:nullptr;}
|
||||
Session* GetSession(){return &session;}Map* GetMap(){return ↦}
|
||||
bool IsInPhase(GameObject*){return phase;}bool CanSeeOrDetect(GameObject*){return visible;}
|
||||
GameObject* GetGameObjectIfCanInteractWith(int guid){return inRange&&guid==go.guid?&go:nullptr;}
|
||||
float GetPositionX(){return x;}float GetPositionY(){return y;}float GetPositionZ(){return z;}
|
||||
std::string GetName(){return "Arthoran";}void StopMoving(){}
|
||||
};
|
||||
void Session::HandleGameObjectUseOpcode(WorldPacket& packet) {
|
||||
++clicks;assert(packet.guid==83);
|
||||
if(accept){player->pending=true;if(far)player->world=false;}
|
||||
}
|
||||
void Session::CompleteClientlessTeleport(){
|
||||
++acks;if(!ackWorks||!player->pending)return;
|
||||
player->pending=false;player->world=true;++sequence;
|
||||
player->mapId=wrongTarget?1:0;player->x=-8998;player->y=861;player->z=29;
|
||||
}
|
||||
struct Node {std::string Key;uint32 MapId=530;};
|
||||
struct Portal {uint32 SpawnId=83,Entry=183325,SpellId=17334;Node Source;};
|
||||
struct Graph {bool available=true;Portal portal;auto GetPortalUse(uint32){return available?&portal:nullptr;}} graph;
|
||||
struct Travel {void CancelTravel(uint32){}} travel;
|
||||
struct Lua {void DismountGroundMount(Player*,const char*){}} lua;
|
||||
auto sClientlessTravelGraphMgr=&graph;auto sClientlessTravelMgr=&travel;auto sClientlessLuaEngine=&lua;
|
||||
enum class ClientlessActionExecutionState {Moving,Failed,Completed};
|
||||
struct Step {uint32 EdgeDatabaseId=9,MapId=0;std::string NodeKey="portal_go_83_target";bool EdgeReverse=false;float X=-8998,Y=861,Z=29;};
|
||||
struct Record {
|
||||
std::vector<Step> Route{{},{}};size_t RouteStep=1;
|
||||
bool PortalUseActive=false,Active=true;uint64 PortalUseTeleportSequence=0;
|
||||
uint32 PortalUseElapsedMs=0,PortalUseSpawnId=0,RecoveryCount=0;
|
||||
std::vector<int> EdgePath;
|
||||
struct {ClientlessActionExecutionState State=ClientlessActionExecutionState::Moving;std::string TargetName,Detail;} Status;
|
||||
};
|
||||
struct ClientlessActionExecutorMgr {
|
||||
int continued=0;
|
||||
bool StartPortalUse(Player*,uint32,Record&);
|
||||
void UpdatePortalUse(Player*,uint32,Record&);
|
||||
bool StartRouteStep(Player*,uint32,Record& r){++continued;r.Active=false;r.Status.State=ClientlessActionExecutionState::Completed;return true;}
|
||||
};
|
||||
'''
|
||||
harness += methods
|
||||
harness += r'''
|
||||
int main(){
|
||||
ClientlessActionExecutorMgr mgr;
|
||||
{Player p;Record r;assert(mgr.StartPortalUse(&p,26,r));assert(p.session.clicks==1&&!p.world);
|
||||
mgr.UpdatePortalUse(&p,26,r);assert(!r.PortalUseActive&&r.RouteStep==2&&p.mapId==0&&p.session.sequence==1);}
|
||||
// The global lifecycle may have ACKed a delayed cast before our next update.
|
||||
{Player p;Record r;assert(mgr.StartPortalUse(&p,26,r));p.session.CompleteClientlessTeleport();
|
||||
mgr.UpdatePortalUse(&p,26,r);assert(r.RouteStep==2&&p.session.acks==1);}
|
||||
{Player p;p.session.far=false;Record r;assert(mgr.StartPortalUse(&p,26,r));
|
||||
mgr.UpdatePortalUse(&p,26,r);assert(r.RouteStep==2);}
|
||||
// Denied native click/cast, even when already standing at the expected target.
|
||||
{Player p;p.session.accept=false;p.mapId=530;p.x=-8998;p.y=861;p.z=29;
|
||||
Record r;r.Route[1].MapId=530;assert(mgr.StartPortalUse(&p,26,r));
|
||||
mgr.UpdatePortalUse(&p,26,r);assert(r.RouteStep==1&&r.Active);
|
||||
r.PortalUseElapsedMs=10000;mgr.UpdatePortalUse(&p,26,r);assert(!r.Active&&r.RouteStep==1);}
|
||||
{Player p;p.session.ackWorks=false;Record r;assert(mgr.StartPortalUse(&p,26,r));
|
||||
r.PortalUseElapsedMs=10000;mgr.UpdatePortalUse(&p,26,r);assert(!r.Active&&p.pending&&r.RouteStep==1);}
|
||||
{Player p;p.session.wrongTarget=true;Record r;assert(mgr.StartPortalUse(&p,26,r));
|
||||
r.PortalUseElapsedMs=10000;mgr.UpdatePortalUse(&p,26,r);assert(!r.Active&&r.RouteStep==1);}
|
||||
{Player p;Record r;assert(mgr.StartPortalUse(&p,26,r));p.session.CompleteClientlessTeleport();p.x+=6;
|
||||
r.PortalUseElapsedMs=10000;mgr.UpdatePortalUse(&p,26,r);assert(!r.Active&&r.RouteStep==1);}
|
||||
for(int kind=0;kind<12;++kind){
|
||||
Player p;Record r;graph.available=true;
|
||||
switch(kind){case 0:graph.available=false;break;case 1:r.RouteStep=0;break;
|
||||
case 2:r.Route[1].EdgeReverse=true;break;case 3:p.mapId=1;break;
|
||||
case 4:p.alive=false;break;case 5:p.pending=true;break;case 6:p.aboard=true;break;
|
||||
case 7:p.session.clientless=false;break;case 8:p.map.objects.clear();break;
|
||||
case 9:p.phase=false;break;case 10:p.inRange=false;break;case 11:p.go.flags=GO_FLAG_NOT_SELECTABLE;break;}
|
||||
assert(!mgr.StartPortalUse(&p,26,r));assert(p.session.clicks==0&&!r.Active);
|
||||
}
|
||||
graph.available=true;
|
||||
{Player p;p.go.info.spellcaster.spell=1;Record r;assert(!mgr.StartPortalUse(&p,26,r));assert(!p.session.clicks);}
|
||||
}
|
||||
'''
|
||||
with tempfile.TemporaryDirectory(prefix='vcore-portal-use-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('Interactive portal native click, near/far/global ACK, denial, timeout and target checks passed.')
|
||||
|
||||
# Exercise native destination discovery rather than duplicating the importer.
|
||||
graph_source = (ROOT / 'src/server/game/World/ClientlessTravelGraphMgr.cpp').read_text()
|
||||
a = graph_source.index('bool ClientlessTravelGraphMgr::ResolvePortalUse(')
|
||||
b = graph_source.index('ClientlessPortalUse const* ClientlessTravelGraphMgr::GetPortalUse', a)
|
||||
definition_method = graph_source[a:b]
|
||||
definition_harness = r'''
|
||||
#include <cassert>
|
||||
#include <string>
|
||||
#include <cstdint>
|
||||
using uint8=uint8_t;using uint32=uint32_t;
|
||||
constexpr uint32 MAX_SPELL_EFFECTS=3,GAMEOBJECT_TYPE_SPELLCASTER=22,SPELL_EFFECT_TELEPORT_UNITS=5,TARGET_DEST_DB=17;
|
||||
using SpellEffIndex=uint8;
|
||||
struct Position {float GetPositionX()const{return -1894;}float GetPositionY()const{return 5387;}float GetPositionZ()const{return -12;}float GetOrientation()const{return 0;}};
|
||||
struct Spawn {uint32 id=183325,mapId=530;Position spawnPoint;} spawn;
|
||||
struct Info {uint32 type=22;struct{uint32 spell=17334;}spellcaster;} info;
|
||||
struct ObjectMgr {bool hasSpawn=true;auto GetGameObjectData(uint32){return hasSpawn?&spawn:nullptr;}auto GetGameObjectTemplate(uint32){return &info;}} objects;
|
||||
struct Target{uint32 id=0;uint32 GetTarget()const{return id;}};
|
||||
struct Effect{uint32 Effect=0;Target TargetA,TargetB;};
|
||||
struct SpellInfo{uint32 Id=17334;Effect Effects[3];} spell;
|
||||
struct SpellTargetPosition {uint32 target_mapId=0;float target_X=-8998,target_Y=861,target_Z=29,target_Orientation=2;} destination;
|
||||
struct SpellMgr {bool hasSpell=true,hasTarget=true;auto GetSpellInfo(uint32){return hasSpell?&spell:nullptr;}auto GetSpellTargetPosition(uint32,SpellEffIndex){return hasTarget?&destination:nullptr;}} spells;
|
||||
auto sObjectMgr=&objects;auto sSpellMgr=&spells;
|
||||
struct Node {std::string Key,Type,WorldPointKey;uint32 MapId=0;float X=0,Y=0,Z=0,O=0;};
|
||||
struct ClientlessPortalUse {uint32 SpawnId=0,Entry=0,SpellId=0;Node Source,Target;};
|
||||
struct ClientlessTravelGraphMgr{static bool ResolvePortalUse(uint32,ClientlessPortalUse&,std::string&);};
|
||||
'''
|
||||
definition_harness += definition_method
|
||||
definition_harness += r'''
|
||||
int main(){
|
||||
spell.Effects[0].Effect=5;spell.Effects[0].TargetB.id=17;
|
||||
ClientlessPortalUse p;std::string error;
|
||||
assert(ClientlessTravelGraphMgr::ResolvePortalUse(83,p,error));
|
||||
assert(p.SpawnId==83&&p.Entry==183325&&p.SpellId==17334&&p.Source.MapId==530&&p.Target.MapId==0);
|
||||
assert(p.Source.Key=="portal_go_83_source"&&p.Target.Key=="portal_go_83_target"&&p.Target.X==-8998);
|
||||
objects.hasSpawn=false;assert(!ClientlessTravelGraphMgr::ResolvePortalUse(83,p,error));objects.hasSpawn=true;
|
||||
info.type=10;assert(!ClientlessTravelGraphMgr::ResolvePortalUse(83,p,error));info.type=22;
|
||||
spells.hasSpell=false;assert(!ClientlessTravelGraphMgr::ResolvePortalUse(83,p,error));spells.hasSpell=true;
|
||||
spells.hasTarget=false;assert(!ClientlessTravelGraphMgr::ResolvePortalUse(83,p,error));spells.hasTarget=true;
|
||||
spell.Effects[0].TargetB.id=0;assert(!ClientlessTravelGraphMgr::ResolvePortalUse(83,p,error));spell.Effects[0].TargetB.id=17;
|
||||
spell.Effects[1].Effect=5;spell.Effects[1].TargetB.id=17;
|
||||
assert(!ClientlessTravelGraphMgr::ResolvePortalUse(83,p,error));spell.Effects[1]={};
|
||||
spell.Effects[0].Effect=77;assert(!ClientlessTravelGraphMgr::ResolvePortalUse(83,p,error));
|
||||
}
|
||||
'''
|
||||
with tempfile.TemporaryDirectory(prefix='vcore-portal-definition-test-') as folder:
|
||||
cpp, binary = Path(folder)/'test.cpp', Path(folder)/'test'
|
||||
cpp.write_text(definition_harness)
|
||||
subprocess.run(['c++', '-std=c++17', str(cpp), '-o', str(binary)], check=True)
|
||||
subprocess.run([str(binary)], check=True)
|
||||
print('Native portal destination discovery and unsupported/ambiguous spell rejection passed.')
|
||||
|
||||
movement_source = (ROOT / 'src/server/game/Handlers/MovementHandler.cpp').read_text()
|
||||
a = movement_source.index('void WorldSession::CompleteClientlessTeleport()')
|
||||
b = movement_source.index('void WorldSession::CompleteNearTeleport(', a)
|
||||
ack_harness = r'''
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
struct Player {bool pending=false,far=false,world=true;bool IsBeingTeleported(){return pending;}bool IsBeingTeleportedFar(){return pending&&far;}bool IsBeingTeleportedNear(){return pending&&!far;}bool IsInWorld(){return world;}};
|
||||
struct WorldSession {
|
||||
Player* player;bool clientless=true,works=true;int near=0,far=0;uint64_t _clientlessTeleportSequence=0;
|
||||
bool IsClientless(){return clientless;}Player* GetPlayer(){return player;}
|
||||
void HandleMoveWorldportAck(){++far;if(works){player->pending=false;player->world=true;}}
|
||||
void CompleteNearTeleport(Player* p){++near;if(works)p->pending=false;}
|
||||
void CompleteClientlessTeleport();
|
||||
};
|
||||
'''
|
||||
ack_harness += movement_source[a:b]
|
||||
ack_harness += r'''
|
||||
int main(){
|
||||
Player p;WorldSession s{&p};s.CompleteClientlessTeleport();assert(s._clientlessTeleportSequence==0);
|
||||
p.pending=true;s.CompleteClientlessTeleport();assert(s.near==1&&s._clientlessTeleportSequence==1);
|
||||
s.CompleteClientlessTeleport();assert(s._clientlessTeleportSequence==1);
|
||||
p.pending=p.far=true;p.world=false;s.CompleteClientlessTeleport();assert(s.far==1&&s._clientlessTeleportSequence==2);
|
||||
p.pending=true;s.works=false;s.CompleteClientlessTeleport();assert(s._clientlessTeleportSequence==2&&p.pending);
|
||||
s.clientless=false;s.works=true;s.CompleteClientlessTeleport();assert(p.pending&&s._clientlessTeleportSequence==2);
|
||||
}
|
||||
'''
|
||||
with tempfile.TemporaryDirectory(prefix='vcore-portal-ack-test-') as folder:
|
||||
cpp, binary = Path(folder)/'test.cpp', Path(folder)/'test'
|
||||
cpp.write_text(ack_harness)
|
||||
subprocess.run(['c++', '-std=c++17', str(cpp), '-o', str(binary)], check=True)
|
||||
subprocess.run([str(binary)], check=True)
|
||||
print('Production clientless ACK completion sequence increments only after completed near/far transfers.')
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Execute production TaxiImport UPDATE statements against enabled/disabled rows."""
|
||||
from pathlib import Path
|
||||
import ast,re,sqlite3
|
||||
source=(Path(__file__).resolve().parents[2]/'src/server/scripts/Custom/viano_bot_commands.cpp').read_text()
|
||||
source=source[source.index('static bool HandleClientlessTaxiImport'):source.index('static bool HandleClientlessPortalImport')]
|
||||
statements=[]
|
||||
for group in re.findall(r'WorldDatabase\.DirectPExecute\(\s*((?:"[^"\n]*"\s*)+),',source):
|
||||
query=''.join(ast.literal_eval(s) for s in re.findall(r'"[^"\n]*"',group))
|
||||
if query.startswith('UPDATE '): statements.append(query)
|
||||
assert len(statements)==2
|
||||
for enabled in (0,1):
|
||||
db=sqlite3.connect(':memory:')
|
||||
db.create_function('IF',3,lambda condition,yes,no:yes if condition else no)
|
||||
db.executescript('CREATE TABLE vcore_clientless_world_point (point_key TEXT,category TEXT,map INT,position_x REAL,position_y REAL,position_z REAL,orientation REAL,enabled INT,comment TEXT); CREATE TABLE vcore_clientless_travel_node (node_key TEXT,node_type TEXT,world_point_key TEXT,enabled INT,comment TEXT);')
|
||||
db.execute('INSERT INTO vcore_clientless_world_point VALUES (?,?,?,?,?,?,?,?,?)',('taxi_585','travel_taxi',1,0,0,0,0,enabled,'manual state'))
|
||||
db.execute('INSERT INTO vcore_clientless_travel_node VALUES (?,?,?,?,?)',('taxi_585','taxi','taxi_585',enabled,'manual state'))
|
||||
db.execute(statements[0]%(1,8334.76,1029.83,22.7917,585,'taxi_585'))
|
||||
db.execute(statements[1]%('taxi_585',585,'taxi_585'))
|
||||
for table in ('vcore_clientless_world_point','vcore_clientless_travel_node'):
|
||||
flag,comment=db.execute('SELECT enabled,comment FROM '+table).fetchone()
|
||||
assert flag==enabled
|
||||
assert comment.startswith('Auto-imported') if enabled else comment=='manual state'
|
||||
print('Taxi importer preserves disabled rows and their reasons.')
|
||||
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise production dock-state/recorder/executor methods with deterministic world doubles.
|
||||
|
||||
Run: python3 tests/clientless/test_transport.py
|
||||
Requires g++ and Python; does not connect to a server or database. Real collision,
|
||||
network visibility and map-transfer integration still require server tests.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def function(text, marker):
|
||||
start = text.index(marker)
|
||||
opening = text.index('{', start)
|
||||
depth = 1
|
||||
end = opening + 1
|
||||
while depth:
|
||||
depth += (text[end] == '{') - (text[end] == '}')
|
||||
end += 1
|
||||
return text[start:end]
|
||||
|
||||
|
||||
source = (ROOT / 'src/server/game/World/ClientlessTransportMgr.cpp').read_text()
|
||||
header = (ROOT / 'src/server/game/World/ClientlessTransportMgr.h').read_text()
|
||||
header = '\n'.join(line for line in header.splitlines() if not line.startswith('#'))
|
||||
header = header.replace('private:', 'public:').replace('TC_GAME_API ', '')
|
||||
core = (ROOT / 'src/server/game/Entities/GameObject/GameObject.cpp').read_text()
|
||||
base = (ROOT / 'src/server/game/Entities/Vehicle/VehicleDefines.h').read_text()
|
||||
position = function(base, ' static void CalculatePassengerPosition(')
|
||||
offset = function(base, ' static void CalculatePassengerOffset(')
|
||||
|
||||
preamble = r'''
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <iostream>
|
||||
#include <limits>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
using uint8=uint8_t; using uint32=uint32_t; using uint64=uint64_t; using int32=int32_t;
|
||||
#define TC_LOG_INFO(...) do {} while(0)
|
||||
#define TC_LOG_WARN(...) do {} while(0)
|
||||
#define TC_LOG_ERROR(...) do {} while(0)
|
||||
struct ObjectGuid {uint32 id=0;uint32 GetCounter() const{return id;}bool operator==(ObjectGuid b)const{return id==b.id;}bool operator!=(ObjectGuid b)const{return id!=b.id;}};
|
||||
struct Position {
|
||||
float x=0,y=0,z=0,o=0;
|
||||
static float NormalizeOrientation(float a){a=std::fmod(a,float(2*M_PI));return a<0?a+2*M_PI:a;}
|
||||
void Relocate(float a,float b,float c,float d=0){x=a;y=b;z=c;o=d;}
|
||||
void Relocate(Position const& p){*this=p;}
|
||||
Position const& GetPosition()const{return *this;}
|
||||
void GetPosition(float&a,float&b,float&c,float&d)const{a=x;b=y;c=z;d=o;}
|
||||
float GetPositionX()const{return x;}float GetPositionY()const{return y;}float GetPositionZ()const{return z;}float GetOrientation()const{return o;}
|
||||
float GetExactDist(Position const*p)const{return std::sqrt((x-p->x)*(x-p->x)+(y-p->y)*(y-p->y)+(z-p->z)*(z-p->z));}
|
||||
};
|
||||
constexpr int MOVE_RUN=0;
|
||||
struct TransportDockState {uint32 Progress=0,Period=0,RemainingPause=0;bool Stopped=false;};
|
||||
class Player; class GameObject;
|
||||
class TransportBase {
|
||||
public:
|
||||
GameObject* go=nullptr;
|
||||
void AddPassenger(Player*);void RemovePassenger(Player*);
|
||||
void CalculatePassengerPosition(float&,float&,float&,float*)const;
|
||||
void CalculatePassengerOffset(float&,float&,float&,float*)const;
|
||||
'''
|
||||
world = r'''
|
||||
};
|
||||
class GameObject:public Position {
|
||||
public:
|
||||
TransportBase base;uint32 map=0,entry=1,spawn=1;TransportDockState state{0,30000,10000,true};
|
||||
GameObject(){base.go=this;}
|
||||
bool IsInWorld()const{return true;}uint32 GetMapId()const{return map;}
|
||||
ObjectGuid GetGUID()const{return {spawn};}
|
||||
TransportBase* ToTransportBase(){return &base;}
|
||||
bool GetTransportDockState(TransportDockState&s)const{s=state;return true;}
|
||||
uint32 deadline=0; uint32 GetUInt32Value(int)const{return deadline;}
|
||||
};
|
||||
struct Session {int completed=0;void CompleteClientlessTeleport() {++completed;}} session;
|
||||
struct Motion {Player*p; void MoveSmoothPath(int,Position* path,int,bool);};
|
||||
class Player:public Position {
|
||||
public:
|
||||
TransportBase* transport=nullptr;uint32 map=0;GameObject* object=nullptr;Motion motion{this};bool pending=false;
|
||||
struct {struct {Position pos;ObjectGuid guid;uint32 time=0;void Reset(){pos={};guid={};time=0;}}transport;}m_movementInfo;
|
||||
bool IsAlive()const{return true;}bool IsFlying()const{return false;}bool IsInFlight()const{return false;}
|
||||
bool IsInWorld()const{return true;}bool IsInPhase(GameObject*)const{return true;}
|
||||
bool IsBeingTeleported()const{return pending;}TransportBase* GetTransport()const{return transport;}
|
||||
ObjectGuid GetGUID()const{return {26};}uint32 GetMapId()const{return map;}
|
||||
float GetSpeed(int)const{return 7;}void StopMoving(){}bool IsSplineEnabled()const{return false;}
|
||||
Motion* GetMotionMaster(){return &motion;}Session* GetSession(){return &session;}
|
||||
};
|
||||
void TransportBase::CalculatePassengerPosition(float&x,float&y,float&z,float*o)const{CalculatePassengerPosition(x,y,z,o,go->x,go->y,go->z,go->o);}
|
||||
void TransportBase::CalculatePassengerOffset(float&x,float&y,float&z,float*o)const{CalculatePassengerOffset(x,y,z,o,go->x,go->y,go->z,go->o);}
|
||||
void TransportBase::AddPassenger(Player*p){p->transport=this;p->m_movementInfo.transport.guid=go->GetGUID();}
|
||||
void TransportBase::RemovePassenger(Player*p){p->transport=nullptr;p->m_movementInfo.transport.Reset();}
|
||||
void Motion::MoveSmoothPath(int,Position* path,int,bool){p->Relocate(path[1]);if(p->transport){Position pos=*p;p->transport->CalculatePassengerOffset(pos.x,pos.y,pos.z,&pos.o);p->m_movementInfo.transport.pos=pos;}}
|
||||
struct Travel {void CancelTravel(uint32){}}travel;
|
||||
auto sClientlessTravelMgr=&travel;
|
||||
// Model the real Field API's strict debug type checks for SMALLINT versus INT.
|
||||
struct Field {
|
||||
bool small=false;uint32 number=0;float real=0;
|
||||
uint32 GetUInt32()const{return small?0:number;}
|
||||
uint16_t GetUInt16()const{return small?uint16_t(number):0;}
|
||||
float GetFloat()const{return real;}
|
||||
};
|
||||
struct Result {Field fields[5]={{true,369,0},{false,0,1},{false,0,2},{false,0,3},{false,0,0}};Field* Fetch(){return fields;}};
|
||||
using QueryResult=Result*;
|
||||
struct Database {Result result;template<class...Args>QueryResult PQuery(char const*,Args...){return &result;}} WorldDatabase;
|
||||
bool Safe(std::string const& key){return !key.empty();}
|
||||
namespace Trinity {bool IsValidMapCoord(float,float,float,float=0){return true;}}
|
||||
'''
|
||||
helpers = '\n'.join(function(source, marker) for marker in [
|
||||
' float Distance(', ' bool SameService(', ' bool LoadNode(', ' Position WorldPoint(', ' float PathLength('
|
||||
])
|
||||
methods = '\n'.join(function(source, marker) for marker in [
|
||||
'bool ClientlessTransportMgr::HasConnection(', 'bool ClientlessTransportMgr::AtDock(',
|
||||
'bool ClientlessTransportMgr::Sample(', 'bool ClientlessTransportMgr::Start(',
|
||||
'void ClientlessTransportMgr::SetPhase(', 'void ClientlessTransportMgr::Cancel(',
|
||||
'void ClientlessTransportMgr::UpdateRide('
|
||||
])
|
||||
stubs = r'''
|
||||
void PublishPassengerState(Player*){}
|
||||
bool ClientlessTransportMgr::EnsureLoaded(){return true;}
|
||||
GameObject* ClientlessTransportMgr::FindObject(Player*p,ClientlessTransportStop const&s)const{return p->object && p->object->entry==s.Entry && p->object->spawn==s.Spawn ? p->object : nullptr;}
|
||||
'''
|
||||
animated = r'''
|
||||
namespace GameTime {uint32 now=0;uint32 GetGameTimeMS(){return now;}}
|
||||
constexpr int GAMEOBJECT_LEVEL=0;
|
||||
struct TransportAnimationEntry {uint32 TimeIndex=0;struct{float X=0,Y=0,Z=0;}Pos;};
|
||||
struct TransportRotationEntry {float X=0,Y=0,Z=0,W=1;};
|
||||
struct TransportAnimation {uint32 TotalTime=30033;std::map<uint32,TransportAnimationEntry const*>Path;std::map<uint32,TransportRotationEntry const*>Rotations;};
|
||||
struct Animated {
|
||||
GameObject _owner;TransportAnimation* _animationInfo=nullptr;uint32 _pathProgress=0;std::vector<uint32>_stopFrames;
|
||||
'''+function(core, ' bool GetTransportDockState(TransportDockState& state) const override').replace(' override','')+'\n};\n'
|
||||
tests = r'''
|
||||
ClientlessTransportPoint point(bool on,float x){ClientlessTransportPoint p;p.OnTransport=on;p.Pos.x=x;return p;}
|
||||
ClientlessTransportStop stop(std::string node,float dock,float land){ClientlessTransportStop s;s.Node=node;s.Entry=1;s.Spawn=1;s.Map=0;s.Period=30000;s.Dock.x=dock;s.Points={point(false,land),point(false,land>dock?land-1:land+1),point(true,1)};return s;}
|
||||
using Phase=ClientlessTransportPhase;
|
||||
void run(ClientlessTransportMgr&m,Player&p,ClientlessTransportMgr::Ride&r,int n=20){for(int i=0;i<n;i++)m.UpdateRide(&p,r,100);}
|
||||
int main(){
|
||||
// Regression: a real-world-point SMALLINT map must survive loading as 369.
|
||||
uint32 loadedMap=0;Position loadedPosition;assert(LoadNode("tram_sw",loadedMap,loadedPosition));assert(loadedMap==369);assert(loadedPosition.x==1&&loadedPosition.y==2&&loadedPosition.z==3);
|
||||
// Actual animation-state method: no state change is required for an animation pause.
|
||||
TransportAnimationEntry a{0,{0,0,0}},b{5000,{0,0,0}},c{15000,{0,0,-60}},d{20000,{0,0,-60}},e{30033,{0,0,0}};
|
||||
TransportAnimation anim;anim.Path={{0,&a},{5000,&b},{15000,&c},{20000,&d},{30033,&e}};
|
||||
Animated animated;animated._animationInfo=&anim;TransportDockState snapshot;
|
||||
animated._pathProgress=1000;assert(animated.GetTransportDockState(snapshot));assert(snapshot.Stopped&&snapshot.RemainingPause==4000);
|
||||
animated._pathProgress=5000;animated.GetTransportDockState(snapshot);assert(!snapshot.Stopped);
|
||||
animated._pathProgress=17000;animated.GetTransportDockState(snapshot);assert(snapshot.Stopped&&snapshot.RemainingPause==3000);
|
||||
TransportRotationEntry r0,r1;r1.Z=1;anim.Rotations={{0,&r0},{30033,&r1}};animated.GetTransportDockState(snapshot);assert(!snapshot.Stopped);
|
||||
anim.Rotations.clear();animated._stopFrames={1000};animated._owner.deadline=2000;GameTime::now=1000;animated.GetTransportDockState(snapshot);assert(!snapshot.Stopped);
|
||||
GameTime::now=2000;animated.GetTransportDockState(snapshot);assert(snapshot.Stopped&&snapshot.RemainingPause==std::numeric_limits<uint32>::max());
|
||||
// Round-trip the actual core transform for a rotated transport.
|
||||
float x=3,y=4,z=5,o=0.7f;TransportBase::CalculatePassengerPosition(x,y,z,&o,100,200,300,1.5708f);TransportBase::CalculatePassengerOffset(x,y,z,&o,100,200,300,1.5708f);
|
||||
assert(std::fabs(x-3)<0.01f&&std::fabs(y-4)<0.01f&&std::fabs(z-5)<0.01f&&std::fabs(o-0.7f)<0.01f);
|
||||
ClientlessTransportMgr mgr;mgr._stops["A"]=stop("A",5,0);mgr._stops["B"]=stop("B",100,104);
|
||||
assert(mgr.HasConnection("A","B"));mgr._stops["B"].Spawn=2;assert(!mgr.HasConnection("A","B"));mgr._stops["B"].Spawn=1;
|
||||
Player p;GameObject go;go.x=5;p.object=&go;std::string error;
|
||||
assert(mgr.Start(&p,"A","B",error));auto&r=mgr._rides.at(26);
|
||||
go.state.RemainingPause=100;run(mgr,p,r);assert(r.Status.Phase==Phase::Waiting&&!p.transport);
|
||||
go.state.RemainingPause=10000;run(mgr,p,r);assert(r.Status.Phase==Phase::Riding&&p.transport==&go.base);assert(std::fabs(p.m_movementInfo.transport.pos.x-1)<0.01);
|
||||
// Wrong location despite a stopped object must not allow disembarkation.
|
||||
go.x=50;p.x=51;run(mgr,p,r);assert(r.Status.Phase==Phase::Riding&&p.transport);
|
||||
go.x=100;p.x=101;run(mgr,p,r);assert(r.Status.Phase==Phase::Arrived&&!p.transport&&p.x==104);
|
||||
// Cancellation on land does not attach a passenger.
|
||||
p.x=0;go.x=5;assert(mgr.Start(&p,"A","B",error));mgr.Cancel(26);auto&cancel=mgr._rides.at(26);run(mgr,p,cancel);assert(cancel.Status.Phase==Phase::Failed&&cancel.Status.Cancelled&&!p.transport);
|
||||
// Midride cancellation retains the Passenger until a recorded safe dock.
|
||||
p.x=0;assert(mgr.Start(&p,"A","B",error));auto&abort=mgr._rides.at(26);run(mgr,p,abort);assert(p.transport);
|
||||
go.x=50;p.x=51;mgr.Cancel(26);run(mgr,p,abort);assert(abort.Status.Phase==Phase::Riding&&p.transport);
|
||||
go.x=5;p.x=6;run(mgr,p,abort);assert(abort.Status.Phase==Phase::Failed&&abort.Status.Cancelled&&!p.transport&&p.x==0);
|
||||
// A timeout aboard never removes the passenger over sea/in midair.
|
||||
assert(mgr.Start(&p,"A","B",error));auto&timeout=mgr._rides.at(26);run(mgr,p,timeout);assert(p.transport);timeout.Elapsed=1800001;run(mgr,p,timeout,1);assert(timeout.Status.Phase==Phase::Failed&&p.transport);go.base.RemovePassenger(&p);
|
||||
// Departure during a boarding transition keeps the Passenger until safe recovery.
|
||||
p.x=0;go.x=5;go.state.Stopped=true;assert(mgr.Start(&p,"A","B",error));auto&late=mgr._rides.at(26);
|
||||
for(int i=0;i<10&&!p.transport;i++)mgr.UpdateRide(&p,late,100);
|
||||
assert(p.transport);go.state.Stopped=false;mgr.UpdateRide(&p,late,100);assert(late.Status.Phase==Phase::Riding&&p.transport);
|
||||
go.state.Stopped=true;run(mgr,p,late);assert(late.Status.Phase==Phase::Failed&&!p.transport);
|
||||
// Recorder captures the coordinate-frame switch and rejects leaving the vessel.
|
||||
p.x=0;ClientlessTransportMgr::Recording rec;rec.Stop=mgr._stops.at("A");rec.Stop.Points={point(false,0)};rec.Object=go.GetGUID();
|
||||
p.x=1;assert(mgr.Sample(&p,rec,true));p.x=6;go.base.AddPassenger(&p);assert(mgr.Sample(&p,rec,true));assert(rec.Stop.Points.back().OnTransport&&std::fabs(rec.Stop.Points.back().Pos.x-1)<0.01);
|
||||
go.base.RemovePassenger(&p);assert(!mgr.Sample(&p,rec,true));assert(!rec.Error.empty());
|
||||
// Boarding onto the wrong vehicle and position jumps do not produce recordings.
|
||||
ClientlessTransportMgr::Recording jump;jump.Stop=mgr._stops.at("A");jump.Stop.Points={point(false,0)};jump.Object=go.GetGUID();p.x=20;assert(!mgr.Sample(&p,jump,true));
|
||||
// Native ships switch between per-map copies of the same spawn. The executor
|
||||
// must resolve the new copy, preserve the ride and leave only at the target.
|
||||
ClientlessTransportMgr ships;
|
||||
ships._stops["harbor_a"]=stop("harbor_a",5,0);ships._stops["harbor_a"].Map=1;
|
||||
ships._stops["harbor_b"]=stop("harbor_b",100,104);ships._stops["harbor_b"].Map=0;
|
||||
Player sailor;GameObject departure;departure.x=5;departure.map=1;sailor.map=1;sailor.object=&departure;
|
||||
assert(ships.Start(&sailor,"harbor_a","harbor_b",error));auto& crossing=ships._rides.at(26);
|
||||
run(ships,sailor,crossing);assert(crossing.Status.Phase==Phase::Riding&&sailor.transport==&departure.base);
|
||||
sailor.pending=true;sailor.object=nullptr;
|
||||
ships.UpdateRide(&sailor,crossing,100);
|
||||
assert(session.completed==1&&crossing.Status.Phase==Phase::Riding); // do not fail during ACK
|
||||
// Model the native worldport ACK's transfer to the destination-map copy.
|
||||
GameObject destination;destination.x=50;destination.map=0;
|
||||
sailor.pending=false;departure.base.RemovePassenger(&sailor);sailor.map=0;sailor.object=&destination;
|
||||
destination.base.AddPassenger(&sailor);sailor.x=51;
|
||||
run(ships,sailor,crossing);assert(crossing.Status.Phase==Phase::Riding&&sailor.transport==&destination.base);
|
||||
destination.x=100;sailor.x=101;
|
||||
run(ships,sailor,crossing);assert(crossing.Status.Phase==Phase::Arrived&&!sailor.transport&&sailor.x==104&&sailor.map==0);
|
||||
std::cout<<"Transport dock, frame, recorder, cross-map copy and ride regressions passed\n";
|
||||
}
|
||||
'''
|
||||
with tempfile.TemporaryDirectory(prefix='vcore-transport-test-') as directory:
|
||||
path = Path(directory)
|
||||
cpp = path / 'transport.cpp'
|
||||
cpp.write_text(preamble + position + '\n' + offset + world + header + helpers + stubs + methods + animated + tests)
|
||||
subprocess.run(['g++', '-std=c++17', '-O0', '-g', '-Wall', '-Wextra', '-Wno-unused-parameter', str(cpp), '-o', str(path / 'test')], check=True)
|
||||
subprocess.run([str(path / 'test')], check=True)
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Exercise actual core grid initialization without bots or a world database."""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def block(source, signature):
|
||||
start = source.index(signature)
|
||||
end = source.index("{", start) + 1
|
||||
depth = 1
|
||||
while depth:
|
||||
depth += (source[end] == "{") - (source[end] == "}")
|
||||
end += 1
|
||||
return source[start:end]
|
||||
|
||||
|
||||
maps = (ROOT / "src/server/game/Maps/Map.cpp").read_text()
|
||||
objects = (ROOT / "src/server/game/Globals/ObjectMgr.cpp").read_text()
|
||||
manager = (ROOT / "src/server/game/Maps/MapManager.cpp").read_text()
|
||||
registration = block(block(objects, "void ObjectMgr::LoadGameObjects()"), "if (gameEvent == 0)")
|
||||
for function in ("Map* MapManager::CreateWorldMap", "InstanceMap* MapManager::CreateInstance",
|
||||
"BattlegroundMap* MapManager::CreateBattleground"):
|
||||
assert "map->LoadTransportGameObjectGrids();" in block(manager, function)
|
||||
assert manager.index("map->LoadRespawnTimes();") < manager.index("map->LoadTransportGameObjectGrids();")
|
||||
|
||||
harness = r'''
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
#include <set>
|
||||
#include <utility>
|
||||
using uint32 = uint32_t;
|
||||
struct ObjectGuid { using LowType = uint32; };
|
||||
#define TC_LOG_INFO(...) ((void)0)
|
||||
constexpr int GAMEOBJECT_TYPE_TRANSPORT = 11;
|
||||
struct Position {
|
||||
float x,y;
|
||||
float GetPositionX() const { return x; }
|
||||
float GetPositionY() const { return y; }
|
||||
};
|
||||
struct GameObjectData { uint32 mapId,spawnMask; Position spawnPoint; };
|
||||
struct Template { int type; };
|
||||
struct ObjectManager {
|
||||
std::unordered_map<uint32,std::vector<uint32>> _transportGameObjectSpawns;
|
||||
std::unordered_map<uint32,GameObjectData> data;
|
||||
unsigned ordinaryGridRegistrations = 0;
|
||||
void AddGameobjectToGrid(uint32,GameObjectData*) { ++ordinaryGridRegistrations; }
|
||||
void Register(int gameEvent, uint32 guid, GameObjectData data, Template const* gInfo) {
|
||||
'''
|
||||
harness += registration
|
||||
harness += r'''
|
||||
}
|
||||
auto GetTransportGameObjectSpawns(uint32 id) const -> std::vector<uint32> const* {
|
||||
auto itr = _transportGameObjectSpawns.find(id);
|
||||
return itr==_transportGameObjectSpawns.end() ? nullptr : &itr->second;
|
||||
}
|
||||
auto GetGameObjectData(uint32 id) const -> GameObjectData const* {
|
||||
auto itr=data.find(id); return itr==data.end() ? nullptr : &itr->second;
|
||||
}
|
||||
} objects;
|
||||
auto* sObjectMgr = &objects;
|
||||
struct Cell { float x,y; Cell(float a,float b):x(a),y(b){} };
|
||||
class Map {
|
||||
public:
|
||||
uint32 id=369, mode=0;
|
||||
std::set<std::pair<int,int>> loaded;
|
||||
uint32 GetId() { return id; }
|
||||
uint32 GetInstanceId() { return 0; }
|
||||
uint32 GetSpawnMode() { return mode; }
|
||||
bool EnsureGridLoaded(Cell c) { return loaded.emplace(int(c.x/533),int(c.y/533)).second; }
|
||||
void LoadTransportGameObjectGrids();
|
||||
};
|
||||
'''
|
||||
harness += block(maps, "void Map::LoadTransportGameObjectGrids()")
|
||||
harness += r'''
|
||||
int main() {
|
||||
Template tram{11}, ship{15}, chest{3};
|
||||
GameObjectData sw{369,1,{4,2490}}, ironforge{369,1,{4,8}};
|
||||
objects.Register(0,18803,ironforge,&tram);
|
||||
objects.Register(0,18804,sw,&tram);
|
||||
objects.Register(7,1,sw,&tram); // inactive/active events retain their own lifecycle
|
||||
objects.Register(-7,2,sw,&tram);
|
||||
objects.Register(0,3,sw,&ship); // separate native moving-transport loader
|
||||
objects.Register(0,4,sw,&chest);
|
||||
assert(objects.ordinaryGridRegistrations==4);
|
||||
assert(objects.GetTransportGameObjectSpawns(369)->size()==2);
|
||||
objects.data.emplace(18803,ironforge); objects.data.emplace(18804,sw);
|
||||
Map map;
|
||||
// Both station grids must load without any player, bot or transport node.
|
||||
map.LoadTransportGameObjectGrids();
|
||||
assert(map.loaded.size()==2);
|
||||
map.LoadTransportGameObjectGrids(); assert(map.loaded.size()==2);
|
||||
Map unrelated; unrelated.id=0; unrelated.LoadTransportGameObjectGrids();
|
||||
assert(unrelated.loaded.empty());
|
||||
Map differentDifficulty; differentDifficulty.mode=1;
|
||||
differentDifficulty.LoadTransportGameObjectGrids(); assert(differentDifficulty.loaded.empty());
|
||||
objects.data.erase(18803); // removed spawn indexed earlier must be skipped
|
||||
objects.data[18804].mapId=0; // likewise a moved spawn
|
||||
Map removed; removed.LoadTransportGameObjectGrids(); assert(removed.loaded.empty());
|
||||
}
|
||||
'''
|
||||
with tempfile.TemporaryDirectory(prefix="vcore-grid-transport-") as folder:
|
||||
source, executable = Path(folder) / "test.cpp", Path(folder) / "test"
|
||||
source.write_text(harness)
|
||||
subprocess.run(["c++", "-std=c++17", str(source), "-o", str(executable)], check=True)
|
||||
subprocess.run([str(executable)], check=True)
|
||||
print("Core transport grid initialization regressions passed (no CPC dependency).")
|
||||
Reference in new issue
Block a user