Support interactive object portals and document travel route recording
GCC PCH / build (push) Canceled after 0s
clang PCH / build (push) Canceled after 0s

Bind directed portal_use edges to native GameObject spawns and spell destinations. Execute normal clicks and verify completed clientless teleports, with regression coverage. Document live portal tests, mage tower calibration, Badlands portals and transport recording workflows.
This commit is contained in:
Nico committed 2026-10-26 14:01:42 +01:00
1 parent 1164a72ea3
commit 4474714c00
18 files changed
+1374 -5

No files matched your search

+59
View File
@@ -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`.
+87
View File
@@ -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`.
+24
View File
@@ -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.
+3 -1
View File
@@ -1,6 +1,8 @@
# Vom Bulk-Import übersprungene gleichkartige Portalübergänge
Bestandsaufnahme vom 26.10.2026, ausschließlich lesender Abgleich der lokalen Testdatenbank mit den installierten AreaTrigger-/Map-DBC-Dateien.
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.
+92
View File
@@ -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.
+151
View File
@@ -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.
+371
View File
@@ -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).
+2
View File
@@ -1,3 +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,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;
@@ -251,10 +251,13 @@ 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)
+2
View File
@@ -744,6 +744,7 @@ class TC_GAME_API WorldSession
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);
@@ -1382,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!
@@ -14,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"
@@ -179,6 +181,7 @@ 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())
@@ -890,6 +893,9 @@ 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;
@@ -1434,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,
@@ -2232,7 +2348,7 @@ void ClientlessActionExecutorMgr::UpdateActive(
ClientlessTravelMgr::StateName(travel->State));
}
void ClientlessActionExecutorMgr::Update(uint32 /*diff*/)
void ClientlessActionExecutorMgr::Update(uint32 diff)
{
for (auto const& [characterGuid, session] : sWorld->GetAllClientlessSessions())
{
@@ -2241,11 +2357,22 @@ void ClientlessActionExecutorMgr::Update(uint32 /*diff*/)
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);
@@ -60,6 +60,10 @@ private:
// 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;
@@ -102,6 +106,8 @@ private:
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);
@@ -6,6 +6,9 @@
#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>
@@ -27,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(
@@ -66,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 "
@@ -101,6 +194,24 @@ 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))
{
@@ -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()
@@ -1331,6 +1331,74 @@ static bool HandleClientlessTaxiInfo(ChatHandler* handler, char const* args)
return true;
}
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;
@@ -2207,6 +2275,9 @@ static bool HandleClientlessTravelReload(ChatHandler* handler, char const* /*arg
{ "info", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessTaxiInfo, "" },
} },
{ "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, "" },
} },
+218
View File
@@ -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 &map;}
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.')