diff --git a/doc/clientless/badlands-dock-portals.md b/doc/clientless/badlands-dock-portals.md new file mode 100644 index 0000000000..9993ef730e --- /dev/null +++ b/doc/clientless/badlands-dock-portals.md @@ -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`. diff --git a/doc/clientless/interactive-portals.md b/doc/clientless/interactive-portals.md new file mode 100644 index 0000000000..7c852bf517 --- /dev/null +++ b/doc/clientless/interactive-portals.md @@ -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`. diff --git a/doc/clientless/portal-selection.md b/doc/clientless/portal-selection.md new file mode 100644 index 0000000000..eb1a0258ff --- /dev/null +++ b/doc/clientless/portal-selection.md @@ -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. diff --git a/doc/clientless/same-map-portals.md b/doc/clientless/same-map-portals.md index 69aea98667..4dfc5799e7 100644 --- a/doc/clientless/same-map-portals.md +++ b/doc/clientless/same-map-portals.md @@ -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. diff --git a/doc/clientless/stormwind-mage-tower.md b/doc/clientless/stormwind-mage-tower.md new file mode 100644 index 0000000000..043abfe49a --- /dev/null +++ b/doc/clientless/stormwind-mage-tower.md @@ -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. diff --git a/docs/interactive-portals.md b/docs/interactive-portals.md new file mode 100644 index 0000000000..4e77d77b93 --- /dev/null +++ b/docs/interactive-portals.md @@ -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 +``` + +Oder in der Testserver-Konsole ohne Punkt: + +```text +clientless portal object import +``` + +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. diff --git a/docs/vehicle-nodes.md b/docs/vehicle-nodes.md new file mode 100644 index 0000000000..f21a76c3a3 --- /dev/null +++ b/docs/vehicle-nodes.md @@ -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 +``` + +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 +``` + +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. + +`` vor Ausführung durch die ermittelte Zahl ersetzen: + +```text +.clientless transport record start ship_hafen_a +``` + +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 +``` + +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 +.clientless transport stand remove +``` + +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 +clientless transport node ship_hafen_a +clientless transport inspect 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). diff --git a/sql/custom/world/.gitignore b/sql/custom/world/.gitignore index 0fe11aa1f5..1c8640b0a1 100644 --- a/sql/custom/world/.gitignore +++ b/sql/custom/world/.gitignore @@ -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 diff --git a/sql/custom/world/2026_10_26_01_clientless_stormwind_mage_tower.sql b/sql/custom/world/2026_10_26_01_clientless_stormwind_mage_tower.sql new file mode 100644 index 0000000000..995b161e62 --- /dev/null +++ b/sql/custom/world/2026_10_26_01_clientless_stormwind_mage_tower.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; diff --git a/sql/custom/world/2026_10_26_02_clientless_portal_use.sql b/sql/custom/world/2026_10_26_02_clientless_portal_use.sql new file mode 100644 index 0000000000..1288947203 --- /dev/null +++ b/sql/custom/world/2026_10_26_02_clientless_portal_use.sql @@ -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; diff --git a/src/server/game/Handlers/MovementHandler.cpp b/src/server/game/Handlers/MovementHandler.cpp index 6b0d4d20e2..60de593924 100644 --- a/src/server/game/Handlers/MovementHandler.cpp +++ b/src/server/game/Handlers/MovementHandler.cpp @@ -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) diff --git a/src/server/game/Server/WorldSession.h b/src/server/game/Server/WorldSession.h index d003349d7f..8f61336dec 100644 --- a/src/server/game/Server/WorldSession.h +++ b/src/server/game/Server/WorldSession.h @@ -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 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! diff --git a/src/server/game/World/ClientlessActionExecutorMgr.cpp b/src/server/game/World/ClientlessActionExecutorMgr.cpp index 83ecb50785..049b0ec0ae 100644 --- a/src/server/game/World/ClientlessActionExecutorMgr.cpp +++ b/src/server/game/World/ClientlessActionExecutorMgr.cpp @@ -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 #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); diff --git a/src/server/game/World/ClientlessActionExecutorMgr.h b/src/server/game/World/ClientlessActionExecutorMgr.h index 620371562d..f91296eac6 100644 --- a/src/server/game/World/ClientlessActionExecutorMgr.h +++ b/src/server/game/World/ClientlessActionExecutorMgr.h @@ -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); diff --git a/src/server/game/World/ClientlessTravelGraphMgr.cpp b/src/server/game/World/ClientlessTravelGraphMgr.cpp index 978c322de0..05118b940c 100644 --- a/src/server/game/World/ClientlessTravelGraphMgr.cpp +++ b/src/server/game/World/ClientlessTravelGraphMgr.cpp @@ -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 #include @@ -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 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)) { diff --git a/src/server/game/World/ClientlessTravelGraphMgr.h b/src/server/game/World/ClientlessTravelGraphMgr.h index 15d8a880c2..becd608ece 100644 --- a/src/server/game/World/ClientlessTravelGraphMgr.h +++ b/src/server/game/World/ClientlessTravelGraphMgr.h @@ -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 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 _nodes; std::unordered_map> _edges; std::unordered_map> _edgePoints; + std::unordered_map _portalUses; }; #define sClientlessTravelGraphMgr ClientlessTravelGraphMgr::instance() diff --git a/src/server/scripts/Custom/viano_bot_commands.cpp b/src/server/scripts/Custom/viano_bot_commands.cpp index 895f08bc8a..8b8eabef71 100644 --- a/src/server/scripts/Custom/viano_bot_commands.cpp +++ b/src/server/scripts/Custom/viano_bot_commands.cpp @@ -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 "); + 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, "" }, } }, diff --git a/tests/clientless/test_portal_use.py b/tests/clientless/test_portal_use.py new file mode 100644 index 0000000000..ec2768ae20 --- /dev/null +++ b/tests/clientless/test_portal_use.py @@ -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 +#include +#include +#include +#include +#include +#include +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 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 Route{{},{}};size_t RouteStep=1; + bool PortalUseActive=false,Active=true;uint64 PortalUseTeleportSequence=0; + uint32 PortalUseElapsedMs=0,PortalUseSpawnId=0,RecoveryCount=0; + std::vector 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 +#include +#include +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 +#include +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.')