26 Commits
Author SHA1 Message Date
Nico ca656403bb Keep clientless flight moving across spline handoffs
GCC PCH / build (push) Waiting to run
clang PCH / build (push) Canceled after 0s
Replace ordinary flight splines before they finish while retaining collision checks, terminal landing and conservative return behavior. Measure actual travelled distance, cover handoff timing with a production Update regression test, retire Arthoran's automatic ship test and document visual acceptance of the travel milestone.
2026-10-26 18:21:48 +01:00
Nico 8df9591c99 Implement dynamic clientless free-flight travel
Integrate transient flight alternatives with graph routing, native flying mounts, phased terrain and collision checks, bounded avoidance and confirmed landing. Add safe-return recovery, node and location integration, profile settings, diagnostic commands, regression tests and flight documentation.
2026-10-26 17:35:58 +01:00
Nico 4474714c00 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.
2026-10-26 14:01:42 +01:00
Nico 1164a72ea3 Implement clientless transports, Lua reload and same-map portals
Add native transport recording and execution with grid lifecycle handling, clientless teleport acknowledgements, and regression tests. Support targeted same-map portal imports and preserve disabled taxi nodes. Document live transport and portal verification and the remaining portal inventory.
2026-10-26 11:27:56 +01:00
Nico 01a9ba4b0b implemented usage of virtual exit and entry nodes
clang PCH / build (push) Waiting to run
GCC PCH / build (push) Canceled after 0s
2026-10-25 12:09:36 +01:00
Nico 56da0d60cf Patch for the portalnode approach and selection
clang PCH / build (push) Canceled after 0s
GCC PCH / build (push) Canceled after 0s
2026-10-19 23:15:11 +02:00
Nico b37a0ddcc4 implemented temporary entry nodes to be considered using playerless MMAP check
GCC PCH / build (push) Waiting to run
clang PCH / build (push) Canceled after 0s
2026-10-19 17:03:42 +02:00
Nico 366615bcb9 implemented usage of virtual entry points when planning the travleroute
GCC PCH / build (push) Canceled after 0s
clang PCH / build (push) Canceled after 0s
2026-10-18 13:39:31 +00:00
Nico 9d4a862b7d implemented regular mounting check when traveling over ground nodes and stopped travel graph from planning routes through unintentioned teleports
clang PCH / build (push) Canceled after 0s
GCC PCH / build (push) Canceled after 0s
2026-10-17 23:53:48 +00:00
Nico 2bcffa5131 fiexed virtuel entry nodes so they will be considered equal to all other Nodes
clang PCH / build (push) Canceled after 0s
GCC PCH / build (push) Canceled after 0s
2026-10-17 21:10:53 +00:00
Nico ae2a4a0f08 added virtual entry nodes
clang PCH / build (push) Canceled after 0s
GCC PCH / build (push) Canceled after 0s
2026-10-17 16:45:58 +00:00
Nico 6e17dedff2 fix in MMAP navigation when traveling to portal node
clang PCH / build (push) Canceled after 0s
GCC PCH / build (push) Canceled after 0s
2026-10-16 22:28:22 +00:00
Nico fe7b5e0f02 importet Areateleports as Nodes
clang PCH / build (push) Canceled after 0s
GCC PCH / build (push) Canceled after 0s
2026-10-16 09:36:33 +00:00
Nico 93c3df1c95 added visible gryphon when bots using taxi-gryphon
clang PCH / build (push) Canceled after 0s
GCC PCH / build (push) Canceled after 0s
2026-10-14 21:23:04 +00:00
Nico 24fb292cee implemented taxinodes
clang PCH / build (push) Canceled after 0s
GCC PCH / build (push) Canceled after 0s
2026-10-14 16:03:14 +00:00
Nico 4c04cd88de fixed travel to location
clang PCH / build (push) Canceled after 0s
GCC PCH / build (push) Canceled after 0s
2026-10-14 13:07:04 +00:00
Nico b14c864814 Implemented travel graph
clang PCH / build (push) Canceled after 0s
GCC PCH / build (push) Canceled after 0s
2026-10-13 19:39:53 +00:00
Nico 05fd748537 core: add server-side lua runtime for clientless players
clang PCH / build (push) Canceled after 0s
GCC PCH / build (push) Canceled after 0s
2026-10-12 16:12:18 +00:00
Nico 5ac9ed673c core: add clientless player manager and controller API
clang PCH / build (push) Canceled after 0s
GCC PCH / build (push) Canceled after 0s
2026-10-12 14:44:45 +00:00
Nico 0c2e333bb4 core: add clientless player lifecycle state machine
clang PCH / build (push) Canceled after 0s
GCC PCH / build (push) Canceled after 0s
2026-10-12 13:20:37 +00:00
Nico 7013087bcd core: add clientless player GM test adapter
GCC PCH / build (push) Canceled after 0s
clang PCH / build (push) Canceled after 0s
2026-10-12 12:51:09 +00:00
Nico fe96d310d6 core: add clientless player session bootstrap
clang PCH / build (push) Canceled after 0s
GCC PCH / build (push) Canceled after 0s
2026-10-12 10:17:43 +00:00
Nico df55203a96 core: add clientless player login entry point
clang PCH / build (push) Canceled after 0s
GCC PCH / build (push) Canceled after 0s
2026-10-12 09:48:45 +00:00
Nico 046cb79b7a core: add clientless session registry and lifecycle 2026-10-11 08:39:16 +00:00
Nico fa8e7d07dd core: add clientless WorldSession mode 2026-10-11 08:23:48 +00:00
Nico 9b4985b5aa Fix player level stats field type reads 2026-10-11 08:01:02 +00:00
86 changed files with 16903 additions and 30 deletions

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
{
"original": {
"graph": "5d632279d7e25748f3bd8e7ffe954482821334bef12088aad885e6a3ef79c6ac",
"executor": "016cdfb51387ab58d3e15a36b91e8fd69381b66962edd62214e4c54868340468",
"header": "61ccbca3b666016a465d8a9a9049dfa5420177e66aa073fdfe927d749ad2b53b"
},
"patched": {
"graph": "a9da61755df58666cfa2c6deb7f5d886760036936bc51b51b91ad4ef0ff76ecd",
"executor": "cbb79474640192c5c853174e219deade3694231060d90163362385db2fd6fb6c",
"header": "64bd460621aa63c3cf7e341173249a5b945091e955e2673523478b0762e89dde"
}
}
@@ -0,0 +1,17 @@
-- Vcore C8.5: local semantic world points for clientless AdventureBots.
CREATE TABLE IF NOT EXISTS `vcore_clientless_world_point` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`point_key` varchar(100) NOT NULL,
`category` varchar(64) NOT NULL,
`map` smallint(5) unsigned NOT NULL DEFAULT 0,
`position_x` float NOT NULL DEFAULT 0,
`position_y` float NOT NULL DEFAULT 0,
`position_z` float NOT NULL DEFAULT 0,
`orientation` float NOT NULL DEFAULT 0,
`enabled` tinyint(1) unsigned NOT NULL DEFAULT 1,
`comment` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_vcore_clientless_world_point_key` (`point_key`),
KEY `idx_vcore_clientless_world_point_category_map` (`category`,`map`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Vcore AdventureBot semantic local world points';
@@ -0,0 +1,31 @@
-- Vcore C8.6: incremental AdventureBot travel graph.
CREATE TABLE IF NOT EXISTS `vcore_clientless_travel_node` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`node_key` varchar(100) NOT NULL,
`node_type` varchar(32) NOT NULL DEFAULT 'location',
`world_point_key` varchar(100) NOT NULL,
`enabled` tinyint(1) unsigned NOT NULL DEFAULT 1,
`comment` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_vcore_clientless_travel_node_key` (`node_key`),
KEY `idx_vcore_clientless_travel_node_world_point` (`world_point_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Vcore AdventureBot travel graph nodes';
CREATE TABLE IF NOT EXISTS `vcore_clientless_travel_edge` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`from_node_key` varchar(100) NOT NULL,
`to_node_key` varchar(100) NOT NULL,
`edge_type` varchar(32) NOT NULL DEFAULT 'ground',
`cost` float NOT NULL DEFAULT 1,
`bidirectional` tinyint(1) unsigned NOT NULL DEFAULT 0,
`enabled` tinyint(1) unsigned NOT NULL DEFAULT 1,
`comment` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_vcore_clientless_travel_edge`
(`from_node_key`,`to_node_key`,`edge_type`),
KEY `idx_vcore_clientless_travel_edge_from` (`from_node_key`),
KEY `idx_vcore_clientless_travel_edge_to` (`to_node_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Vcore AdventureBot travel graph edges';
@@ -0,0 +1,14 @@
-- Vcore C8.7: recorded geometry for bidirectional ground edges.
CREATE TABLE IF NOT EXISTS `vcore_clientless_travel_edge_point` (
`edge_id` int(10) unsigned NOT NULL,
`point_index` int(10) unsigned NOT NULL,
`map` smallint(5) unsigned NOT NULL DEFAULT 0,
`position_x` float NOT NULL DEFAULT 0,
`position_y` float NOT NULL DEFAULT 0,
`position_z` float NOT NULL DEFAULT 0,
`orientation` float NOT NULL DEFAULT 0,
PRIMARY KEY (`edge_id`,`point_index`),
KEY `idx_vcore_clientless_travel_edge_point_edge` (`edge_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Recorded geometry for Vcore AdventureBot travel graph edges';
+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`.
@@ -0,0 +1,162 @@
# Freie Flugreisen
Die Umsetzung ergänzt die Planungsgrundlage in [free-flying.md](free-flying.md).
Freies Fliegen ist eine vorübergehend berechnete Alternative im Fernreisesystem.
Es werden dafür weder Nodes noch Kanten in der Datenbank aufgezeichnet.
## Reiseplanung
Ein Flug kann an einem regulären Graphnode oder einem virtuellen Straßenausstieg
beginnen und an einem regulären Node oder einer Location enden. Virtuelle
Straßeneinstiege sind keine Flugziele. Der Abflugpunkt muss tatsächlich erreicht
sein; ein Bot außerhalb des Graphen erhält keinen beliebigen direkten Luftstart.
Normale Straßenreisen haben einen Kostenvorteil. Flug wird bei genügend großem
Vorteil berücksichtigt; bevorzugte Flugzonen senken Mindestdistanz und Kosten.
Damit können dort auch die Anschlussstrecken zwischen Nodes und Locations fliegen.
Die Vorgabe meint insbesondere Gebiete, deren Aufbau und gefährliche Gegner
Bodenreisen ungeeignet machen. Die Kosten sind Planungsgewichte, keine Sekunden.
Scheitert eine Flugoption am Boden, wird nur diese Option für die aktuelle Reise
gesperrt und mit den übrigen Möglichkeiten erneut geplant.
Die Planung berücksichtigt Kartentyp, bekannte Gebietsregeln, Reitfähigkeit und
Lizenzen. Instanzen sowie bekannte Flugverbote wie Immersangwald und Geisterlande
werden ausgeschlossen. Dynamische Schlachtfeldregeln bleiben zur Laufzeit maßgeblich.
Unbekannte Gebietsmetadaten erlauben lediglich einen Planungsversuch: Beim Start
entscheiden die normalen Mountfähigkeiten und Zauberprüfungen des Servers.
Vashj'irs besondere Unterwasserreisen sind nicht enthalten.
## Profil und Konfiguration
Im `movement`-Abschnitt des Charakterprofils:
```lua
can_free_fly = true,
flight_mounts = { [32235] = 100 }, -- Zauber-ID: Goldener Greif; Gewicht 100
```
Flugmounts verwenden **gelernte Mountzauber**, im Unterschied zu den bisherigen
Display-IDs der Bodenmounts. Die Auswahl erfolgt gewichtet nach der Routenwahl.
Benötigt werden mindestens Expertenreiten (225), ein geeignetes gelerntes Flugtier
und die native Flugberechtigung. Für Karten 0/1 wird die Flugmeisterlizenz (90267),
für Nordend Kaltwetterflug (54197) berücksichtigt. Ein Profil allein erteilt keine
Fähigkeiten. Arthorans Profil enthält den bereits bereitgestellten Greifen.
```ini
Clientless.Flight.Enabled = 1
Clientless.Flight.Clearance = 60
Clientless.Flight.MinDistance = 600
Clientless.Flight.PreferredZones = "210 4197"
```
`Clearance` ist der nominelle Geländeabstand in Yards (begrenzt auf 20–200).
`MinDistance` gilt außerhalb bevorzugter Zonen, dort mindestens 100 Yards;
in bevorzugten Zonen sind Flüge ab 60 Yards planbar. Die voreingestellten
Zonen sind Eiskrone und Tausendwinter; aktuelle Flugverbote gelten weiterhin.
Konfigurationsänderungen können über `reload config` übernommen werden.
## Flugausführung
Nach dem normalen Mountzauber wartet die Steuerung auf die tatsächliche native
Flugfähigkeit. Es gibt keine künstliche Flugfreigabe und keinen Zielteleport.
Die Bewegung erfolgt in kurzen nativen Flugsplines mit Flugmountgeschwindigkeit.
Im normalen Flug wird die nächste Spline bis zu 600 ms vor dem Ende der laufenden
Spline ab der aktuellen Position gestartet. Dadurch entsteht zwischen den
Abschnitten kein regulärer Halt. Der neue Korridor wird vollständig geprüft;
Landungen und die konservative Rückkehr über die Flugspur erreichen weiterhin
ihre Endpunkte. Die Wegstrecke zählt tatsächlich geflogene Bewegung, nicht die
überlappenden Längen der erteilten Bewegungsaufträge. `status` zeigt mit
`handoffs` die Anzahl der Übergaben während einer noch laufenden Spline.
Die Navigation prüft Gelände und Wasseroberfläche, native maximale Flughöhe,
VMAP-Geometrie und kollidierbare dynamische GameObjects in der jeweiligen Phase.
Mehrere versetzte Kollisionsstrahlen approximieren die Körperbreite und -höhe.
Vorausschau und geglättete Geländehöhe verhindern das Nachfahren jeder kleinen
Spitze; längere Absenkungen erhalten eine Verzögerung. Höhenänderungen erfolgen
diagonal. Hindernisse können moderate seitliche Umwege oder zusätzliche Steigung
auslösen; die maximale Flughöhe bleibt eine harte Grenze.
Für Landungen wird fester Boden mit freiem Luftraum gesucht. Ersatzpunkte liegen
höchstens 20 Yards neben dem Ziel und benötigen einen kurzen vollständigen
MMAP-Anschluss zum eigentlichen Ziel. Auf Wasser wird nicht gelandet. Nach
bestätigtem Bodenkontakt endet die Flugsteuerung, gegebenenfalls folgt die lokale
Bodenstrecke; danach setzt die normale Graph- beziehungsweise Intentlogik fort.
## Diagnosebefehle
In der Serverkonsole ohne führenden Punkt, ingame mit Punkt:
```text
clientless flight status 26
clientless flight probe 26 -10100 -600
clientless flight test_node 26 northshire
clientless flight abort 26
```
`status` zeigt Zustand, Fortschritt und Fehler; `probe` prüft Gelände und
Flughöhenbegrenzung auf der aktuellen Karte ohne Bewegung. `test_node` ist ein
isolierter Ausführungstest zwischen einem tatsächlich erreichten regulären
Abflugnode und einem regulären Zielnode derselben Karte. Er verlangt einen
untätigen Bot und ersetzt keinen Test der normalen Reiseplanung.
`abort` bricht einen ausstehenden Mountzauber ab oder versucht in der Luft eine
Rückkehr über die aufgezeichneten, erneut kollisionsgeprüften Flugabschnitte.
Erst nach der Landung wird abgestiegen. Ist auch die Rückstrecke blockiert,
stoppt der Bot gemountet und meldet den Fehler; dieser Sonderfall benötigt
weitere Betreuung. Neue Bodenaktionen werden in der Luft abgelehnt.
Das Log `adventurebot-navigation.log` enthält `FLIGHT_START`, `FLIGHT_SEGMENT`,
`FLIGHT_RETURN`, `FLIGHT_FAILED` und `FLIGHT_LANDED`.
## Grenzen und Prüfungen
Die erste Umsetzung verwendet begrenzte lokale Vorausschau und einen begrenzten
Umweg, keinen globalen dreidimensionalen Pfadsolver. Fehlende Geometriedaten
werden abgelehnt. Komplexe Gebäude, Schluchten und vollständig umschlossene Ziele
können eine Flugoption scheitern lassen. Spieler und NPC-Körper bilden keine
zusätzlichen Hindernisse über die native Weltgeometrie hinaus. Schauflüge und
Tagesroutinen gehören weiterhin in die spätere Verhaltenssteuerung.
Automatisierte Prüfungen:
```bash
python3 tests/clientless/test_free_flight.py
python3 tests/clientless/test_free_flight_planner.py
python3 tests/clientless/test_flight_policy.py
python3 tests/clientless/test_flight_handoff.py
```
Sie prüfen die produktive Geometrie und Planungslogik mit kontrollierten Daten:
Start/Landung, geglättete Spitzen, Flughöhenlimit, seitliches/vertikales Ausweichen,
fehlende Geometrie, hohen Zielanflug, dynamische Flugoptionen, Straßenpräferenz,
virtuelle Ausstiege, Flugverbote und Fehlerbehandlung. Build und Servertests
ersetzen keine abschließende visuelle Abnahme im Spiel.
Serverseitige Testbeobachtungen am 26.10.2026 mit Arthoran (GUID 26):
- Goldhain → Nordhain und zurück: natives Aufmounten, Flugsegmente und bestätigte Landung.
- Manueller Abbruch in der Luft: Rückflug entlang der geprüften Spur und Landung am Ausgangspunkt.
- Reguläres `travel_to_location`: Kombination bestehender Wege mit Flug ab virtuellem Straßenausstieg, Landung und `travel-location-complete`.
- Ein nicht sicher fortsetzbarer Abflug am Node `taxi_12` führte zu Rückkehr und Neuplanung statt einer erfundenen Ankunft.
Das Testziel wurde nur temporär als Worldpoint angelegt. Dafür wurden keine
Flugnodes oder Flugkanten gespeichert.
Der abschließende reguläre Location-Test mit dem verbesserten Anflug endete um
16:44:19 mit `FLIGHT_LANDED` (32 Segmente, Zielabweichung 0,00) und anschließendem
`travel-location-complete`. Der Test lief über die normale Reiseaktion und den
virtuellen Straßenausstieg, nicht über `flight test_node`.
## Sichtprüfung und Abschluss
Beim beobachteten Flug Goldhain → Nordhain fiel zunächst ein kurzes Anhalten
zwischen den Abschnitten auf. Die vorzeitige Spline-Übergabe behebt diese Lücke.
Der anschließende Rückflug Nordhain → Goldhain landete am 26.10.2026 um 18:09:14
nach 36 Abschnitten mit Zielabweichung 0,00. Das Log bestätigt Übergaben während
noch laufender Splines. Der Nutzer hat den Flug anschließend ausdrücklich als
sauber und flüssig bestätigt.
Damit ist der Entwicklungsmeilenstein Navigation und Fernreisen einschließlich
unabhängiger Flugreisen abgenommen. Die oben genannten Grenzen der begrenzten
Flugnavigation bleiben bestehen; weitere Strecken können schrittweise ergänzt
und geprüft werden. Mikroaktionen folgen als eigener Entwicklungsschritt.
+48
View File
@@ -0,0 +1,48 @@
1. Freies Fliegen als eigenständige Reiseart
Freies Fliegen soll eine reguläre Reisemöglichkeit der AdventureBots werden, neben Laufen, Reiten und Greifenflügen. Es ist ausdrücklich von den festen Flugrouten zwischen Greifenmeistern zu unterscheiden.
2. Wann AdventureBots fliegen sollen
- Grundsätzlich sollen Bots aus RP-Gründen Straßen und Reittiere bevorzugen.
- Freies Fliegen soll bei einem deutlichen Reisevorteil infrage kommen.
- Besonders sinnvoll ist es in Gebieten, in denen Bodenreisen ungeeignet oder extrem umständlich sind, beispielsweise Eiskrone oder Tausendwinter.
- Zonenregeln dürfen ausdrücklich festlegen, dass Fliegen in bestimmten Gebieten bevorzugt werden soll.
- Auch kürzere Reisen können perspektivisch durch Laufen, Reiten oder freies Fliegen zurückgelegt werden.
3. Technische Flugnavigation
Freies Fliegen benötigt ein eigenes Navigationssystem und darf nicht einfach als gewöhnliche TravelGraph-Kante umgesetzt werden.
Wir hatten folgenden Ablauf festgelegt:
Start / Takeoff
3D-Luftnavigation
Zielanflug / Approach
Landung / Landing
Dabei müssen Flughöhe, Hindernisse, geeignete Landepunkte und die Übergänge zwischen Boden- und Flugbewegung berücksichtigt werden.
4. Auswahl des Flugmounts
Die Entscheidung, ob geflogen wird, ist von der Auswahl des konkreten Flugmounts getrennt.
Wählt die Reiseplanung freies Fliegen, darf das Charakterprofil bestimmen, welches passende Flugtier verwendet wird.
5. RP-Flüge und Tagesroutinen
Wir hatten auch gelegentliche Schauflüge hochstufiger Abenteurer über Sturmwind besprochen, beispielsweise mit einer passenden Landung vor dem Auktionshaus.
Dabei wurde ausdrücklich festgelegt: Solche RP-Schauflüge gehören in die spätere Tagesroutine beziehungsweise Verhaltenssteuerung, nicht in das Navigationssystem selbst.
Das Navigationssystem übernimmt lediglich die tatsächliche Durchführung des Fluges.
6. Entwicklungsreihenfolge
Wir hatten folgende Reihenfolge akzeptiert:
Portale und Instanzen → Schiffe und Luftschiffe → Tiefenbahn → freie Flugreisen.
Freies Fliegen war damit als späterer eigenständiger Navigationsschritt vorgesehen. Eine erfolgreiche Implementierung oder ein abgeschlossener Funktionstest wurde bislang nicht bestätigt.
Das ist der bisher besprochene Stand, ohne zusätzliche Umsetzungsvorschläge.
+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`.
+41
View File
@@ -0,0 +1,41 @@
# Lua im laufenden Testserver ändern
`.clientless luareload 26` liest Arthorans Profil und Adventure-Skript erneut von
der Festplatte und führt das Verhalten einmal aus. In der Serverkonsole entfällt
der führende Punkt. Der Befehl benötigt GM-Rechte und funktioniert für jeden
eingeloggten, verwalteten clientlosen Spieler anhand seiner GUID.
Die Antwort enthält den tatsächlich verwendeten absoluten Skriptpfad. Es gelten
weiterhin die vorhandenen Fallbacks: `adventure/profiles/<name>.lua` oder
`adventure/profiles/generic.lua` sowie `adventure/overrides/<name>.lua` oder
`adventure/generic.lua`. Die Engine hält keine kompilierten Skripte im Cache.
Für Entwicklung kann `worldserver.conf` direkt auf die Quelldateien zeigen:
```ini
Clientless.ScriptRoot = "/home/wow_admin/wow_cata_server/source/TrinityCore/src/server/scripts/Custom/clientless"
```
Der Standard bleibt `scripts/clientless`, relativ zum Arbeitsverzeichnis des
Worldservers. Eine nichtleere Umgebungsvariable `VCORE_CLIENTLESS_SCRIPT_DIR`
hat Vorrang. Eine Änderung des Stammverzeichnisses erfordert einen Neustart;
anschließende Lua-Dateiänderungen benötigen weder Build noch Installation oder
Neustart. Die vorhandene Installation der Lua-Dateien bleibt für eigenständige
Serverinstallationen erhalten.
Der Reload setzt einen untätigen Bot mit leerer Aktionswarteschlange voraus.
Laufende Reisen, Flüge, Teleports und Passagiere auf Transportmitteln werden
abgewiesen, ohne diese abzubrechen. Der Befehl hängt daher keine zusätzliche
Reise an eine bereits laufende Reise an. Ein erfolgreicher Reload kann sofort
die im geänderten Skript eingereihten Aktionen starten.
Profil und Verhalten werden zusammen geladen. Bei Lade- oder Laufzeitfehlern
bleibt das bisherige Profil erhalten. Während des Reloads angeforderte Aktionen
und Intent-Änderungen werden erst nach Erfolg übernommen. Lua-Logmeldungen
können auch aus einem später fehlgeschlagenen Reload stammen. Fehlerdetails
stehen im Serverlog; das Ergebnis wird zusätzlich als `LUA_RELOAD` im
Navigationslog protokolliert. Lokale Variablen des alten Skriptaufrufs werden
nicht fortgeführt; das entspricht dem bisherigen einmaligen Login-Aufruf.
Regressionstest: `python3 tests/clientless/test_lua_reload.py` (C++-Compiler und
Lua-5.4-Entwicklungspaket erforderlich).
+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.
+65
View File
@@ -0,0 +1,65 @@
# Rut’theran / Darnassus: Graphkorrektur vom 26.10.2026
`taxi_585` wurde auf GM-Anweisung ausschließlich im CPC-TravelGraph und dessen
Weltpunkttabelle deaktiviert. Der Punkt bei (8334.76, 1029.83, 22.79) ist kein
regulärer Spieler-Flugpunkt. Es waren keine Kanten mit diesem Node verbunden.
Native Taxi-/NPC-Definitionen bleiben erhalten. Die explizite Testmigration
steht in `sql/custom/world/2026_10_26_00_clientless_disable_taxi_585.sql`.
Der Taxi-Importer behält bestehende `enabled=0`-Flags und deren Kommentare bei,
anstatt sie bei jedem Neuimport zu aktivieren. Neue Taxi-Datensätze werden wie
bisher aktiviert angelegt. Ein SQL-Regressionstest führt die tatsächlichen
UPDATE-Anweisungen mit aktiven und deaktivierten Datensätzen aus:
`python3 tests/clientless/test_taxi_import_disabled.py`.
## Fehlende Portale
Der frühere Portalimport übersprang gleichkartige Übergänge, und der Executor
lehnte sie ausdrücklich ab. Rut’theran und Darnassus liegen beide auf Karte 1.
Die allgemeine Clientless-Near-Teleport-Bestätigung ist seit der
Transportintegration vorhanden und wird jetzt auch vom Portal-Executor benutzt.
Portalreisen durchlaufen weiterhin den normalen AreaTrigger-Handler mit dessen
Zugangsprüfungen. Vor Fortsetzung muss der Teleport abgeschlossen sein und die
Position höchstens fünf Yards vom tatsächlichen Ziel entfernt liegen. Dadurch
kann ein verweigerter gleichkartiger Teleport nicht als Erfolg gelten.
`.clientless portal import <triggerId>` erlaubt nun einen gezielten Import,
einschließlich gleichkartiger Portale. Der bisherige Bulk-Import ohne Argument
behält seinen Umfang (Kartenwechsel) bei. `portal import` und `portal info` sind
mit denselben GM-Rechten auch über die Konsole nutzbar.
Aus den installierten DBC-/DB-Daten abgeleitete Nodes, alle auf Karte 1:
| Ort | Node | X | Y | Z |
| --- | --- | --- | --- | --- |
| Rut’theran, nach Darnassus | portal_at_542_source | 8393.800 | 1003.900 | 30.191 |
| Darnassus, Ankunft | portal_at_542_target | 9946.250 | 2612.970 | 1316.490 |
| Darnassus, nach Rut’theran | portal_at_527_source | 9947.480 | 2630.040 | 1318.600 |
| Rut’theran, Ankunft | portal_at_527_target | 8380.560 | 998.536 | 29.129 |
Gerichtete Portalkanten: 542_source → 542_target (ID 1747) und
527_source → 527_target (ID 1746), Kosten jeweils 1. Groundanschlüsse werden
separat aufgezeichnet oder vom Planner vollständig per MMAP validiert.
## Verifikation
Build, Installation und Regressionstests bestanden. Echter Live-Test mit
Arthoran: Trigger 542 um 10:26:22, Trigger 527 um 10:27:17, jeweils
`EDGE_COMPLETE`, `arrivalError=0.00`, Recovery 0. Die Rückfahrt nutzte zunächst
eine validierte MMAP-Brücke von `portal_at_542_target` zum 17,24 Yards entfernten
`portal_at_527_source`; danach wurde der reguläre AreaTrigger ausgeführt.
`tests/clientless/test_portal_transfer.py` prüft den tatsächlichen
Handler-/ACK-/Zielprüfungsblock mit Core-Doubles: Near und Far, abgewiesener
Same-Map-Teleport, fehlender ACK-Abschluss und falscher Sessiontyp.
Die existierenden Transport- und Lua-Reload-Regressionstests bestanden ebenfalls.
Ausgangsdaten, neu erzeugte Portalnodes und vorheriges Arthoran-Lua liegen unter
`/tmp/vcore-ruttheran-fix.yxm90j91`. Ein laufender GM-Aufnahmebuffer wurde vor
dem Neustart ausdrücklich als gespeichert bestätigt und im Log nachgewiesen.
Die abschließend installierte Build-ID ist
`f2873b12546ada19e68aa3b6b0d8e7823fe816e5` (einschließlich korrigierter
`portal info`-Anzeige). Ready-Meldung, laufender Prozess 42675 und Port 8085
wurden nach dem finalen Start geprüft. Arthoran wurde nach beiden Tests sauber
ausgeloggt; das vorherige Lua-Skript ist wiederhergestellt.
File diff suppressed because it is too large. Load diff
+101
View File
@@ -0,0 +1,101 @@
# Vom Bulk-Import übersprungene gleichkartige Portalübergänge
Historische Bestandsaufnahme vom 26.10.2026 vor dem Magierturm-Import, ausschließlich lesender Abgleich der lokalen Testdatenbank mit den installierten AreaTrigger-/Map-DBC-Dateien.
Nachtrag: 702/704 und 6321/6322 sind inzwischen importiert und als Portalübergänge live getestet. Damit bleiben 54 der hier ursprünglich fehlenden 58 Übergänge unimportiert; das bedeutet keine geplante Freigabe dieser restlichen Übergänge. Die aktuelle Auswahl steht in [portal-selection.md](portal-selection.md), die Ödland-Tests in [badlands-dock-portals.md](badlands-dock-portals.md). Der noch fehlende Stadtanschluss ist in [stormwind-mage-tower.md](stormwind-mage-tower.md) dokumentiert. Die folgende Tabelle und die JSON-Datei zeigen weiterhin den Vorherstand.
Teleportdefinitionen: 316; davon gleichkartig: 60, kartenwechselnd: 256, ohne passenden DBC-Trigger: 0.
Aktuell vollständig aktiv im Graph: 2 gleichkartige Übergänge. Der Bulk-Importer überspringt sie weiterhin; gezielter Import ist möglich. Keine Daten wurden verändert.
## Einordnung der 58 zusätzlich fehlenden Übergänge
Alle 58 fehlen sowohl als Source-Node und Target-Node als auch als Portal-Kante: insgesamt 116 fehlende Nodes und 58 fehlende gerichtete Kanten. Die bereits ergänzten Trigger 527 und 542 sind vollständig aktiv.
| Gruppe nach DB-Bezeichnung/Kartentyp | Trigger | Anzahl |
| --- | --- | --- |
| Sturmwind, Magierturm | 702, 704 | 2 |
| Immersangwald, Duskwither-Teleporter | 4233, 4267 | 2 |
| Dalaran, Brunnen | 5273 | 1 |
| Acherus/Ebon Hold, interne Ebenenübergänge | 5127–5138 | 12 |
| Hyjal/Feuerlande, Outdoor-Übergänge | 5879, 5880, 5893, 5895, 5931, 5937 | 6 |
| Ödland, Klippen-/Anlegerübergänge | 6321, 6322 | 2 |
| Tausend Nadeln, Test of Faith | 943 | 1 |
| Scherbenwelt, Invasion Point | 4561, 4562 | 2 |
| Hyjal-Schlachtzug, Basisübergänge | 4311–4313 | 3 |
| Hallen der Reflexion, Türübergang | 5740 | 1 |
| Verlorene Inseln, Volcanoth/Phase 8+ | 5926 | 1 |
| Arena-Start-/Umsetzungstrigger auf fünf Arenakarten | siehe vollständige Liste | 25 |
Diese Gruppen sind eine Einordnung anhand der gespeicherten Namen und nativen Kartentypen. Sie ersetzen keine Prüfung der Quest-, Fraktions-, Phasen- und Instanzbedingungen. Insbesondere Arena-Starttrigger sind keine frei nutzbaren überregionalen Reisekanten.
Als nächsten gezielten Kandidaten zuerst **702/704 in Sturmwind** prüfen, danach nach Bedarf Duskwither und den Dalaran-Brunnen. Den übrigen Bestand erst nach Prüfung seiner tatsächlichen Spielmechanik und Zugänglichkeit ergänzen. Es wurde nichts importiert, aktiviert oder am laufenden Server geändert.
## Vollständiger Bestand der 60 gleichkartigen Übergänge
| Trigger | Name laut DB | Karte | Graphstatus | Source X/Y/Z | Target X/Y/Z |
| --- | --- | --- | --- | --- | --- |
| 527 | Darnassus Exit Target | 1 – Kalimdor | aktiv | 9947.48 / 2630.04 / 1318.60 | 8380.56 / 998.54 / 29.13 |
| 542 | Darnassus Entrance Target | 1 – Kalimdor | aktiv | 8393.80 / 1003.90 / 30.19 | 9946.25 / 2612.97 / 1316.49 |
| 702 | Stormwind - Mage Exit Target | 0 – Eastern Kingdoms | fehlt/unvollständig | -9020.67 / 890.10 / 34.00 | -9014.94 / 873.33 / 148.62 |
| 704 | Stormwind - Mage Entrance Target | 0 – Eastern Kingdoms | fehlt/unvollständig | -9017.37 / 877.30 / 152.00 | -9016.97 / 885.44 / 29.62 |
| 943 | Thousand Needles - Test of Faith (Target) | 1 – Kalimdor | fehlt/unvollständig | -4950.74 / -1595.14 / -3.02 | -5191.27 / -2802.59 / -7.21 |
| 4233 | Eversong Woods - Duskwither UP Target | 530 – Outland | fehlt/unvollständig | 9336.22 / -7883.46 / 74.91 | 9331.49 / -7812.27 / 136.57 |
| 4267 | Eversong Woods - Duskwither DOWN Target | 530 – Outland | fehlt/unvollständig | 9285.97 / -7916.01 / 176.13 | 9334.03 / -7880.02 / 74.91 |
| 4311 | CoT - Hyjal - Human Base - Teleport Target | 534 – The Battle for Mount Hyjal | fehlt/unvollständig | 4162.02 / -4227.07 / 869.18 | 5066.79 / -1791.90 / 1321.65 |
| 4312 | CoT - Hyjal - Orc Base - Teleport Target | 534 – The Battle for Mount Hyjal | fehlt/unvollständig | 4284.15 / -4138.85 / 866.81 | 5499.96 / -2756.80 / 1488.96 |
| 4313 | CoT - Hyjal - Night Elf Base - Teleport Target | 534 – The Battle for Mount Hyjal | fehlt/unvollständig | 4203.64 / -4103.24 / 878.36 | 5163.02 / -3428.31 / 1627.61 |
| 4561 | Invasion Point, Cataclysm (Return Point) | 530 – Outland | fehlt/unvollständig | -2829.78 / 1953.76 / 202.46 | -3278.63 / 2831.31 / 123.01 |
| 4562 | Invasion Point, Cataclysm (Return Point) | 530 – Outland | fehlt/unvollständig | -2745.24 / 2730.42 / 127.78 | -3278.63 / 2831.31 / 123.01 |
| 4917 | Arena - Nagrand - Teleport Target - Game On | 559 – Nagrand Arena | fehlt/unvollständig | 4054.15 / 2923.70 / -40.00 | 4054.57 / 2923.23 / 13.82 |
| 4919 | Arena - Bladesedge - Teleport Target - Game On | 562 – Blade's Edge Arena | fehlt/unvollständig | 6188.01 / 320.00 / 20.00 | 6238.89 / 264.15 / 0.89 |
| 4921 | Arena - Bladesedge - Teleport Target - Game On | 562 – Blade's Edge Arena | fehlt/unvollständig | 6288.22 / 210.00 / 20.00 | 6238.89 / 264.15 / 0.89 |
| 4922 | Arena - Bladesedge - Teleport Target - Game On | 562 – Blade's Edge Arena | fehlt/unvollständig | 6214.87 / 200.26 / 20.00 | 6238.89 / 264.15 / 0.89 |
| 4923 | Arena - Bladesedge - Teleport Target - Game On | 562 – Blade's Edge Arena | fehlt/unvollständig | 6187.77 / 196.88 / 20.00 | 6238.89 / 264.15 / 0.89 |
| 4924 | Arena - Bladesedge - Teleport Target - Game On | 562 – Blade's Edge Arena | fehlt/unvollständig | 6264.76 / 329.75 / 3.41 | 6238.89 / 264.15 / 0.89 |
| 4925 | Arena - Bladesedge - Teleport Target - Game On | 562 – Blade's Edge Arena | fehlt/unvollständig | 6290.77 / 333.41 / 20.00 | 6238.89 / 264.15 / 0.89 |
| 4927 | Arena - Undercity - Teleport Target - Game On | 572 – Ruins of Lordaeron | fehlt/unvollständig | 1200.33 / 1682.09 / 33.08 | 1286.57 / 1667.40 / 39.60 |
| 4928 | Arena - Undercity - Teleport Target - Game On | 572 – Ruins of Lordaeron | fehlt/unvollständig | 1223.61 / 1630.81 / 32.61 | 1286.57 / 1667.40 / 39.60 |
| 4929 | Arena - Undercity - Teleport Target - Game On | 572 – Ruins of Lordaeron | fehlt/unvollständig | 1359.18 / 1692.25 / 37.07 | 1286.57 / 1667.40 / 39.60 |
| 4930 | Arena - Undercity - Teleport Target - Game On | 572 – Ruins of Lordaeron | fehlt/unvollständig | 1371.51 / 1649.08 / 34.51 | 1286.57 / 1667.40 / 39.60 |
| 4931 | Arena - Undercity - Teleport Target - Game On | 572 – Ruins of Lordaeron | fehlt/unvollständig | 1299.76 / 1554.74 / 40.00 | 1286.57 / 1667.40 / 39.60 |
| 4932 | Arena - Undercity - Teleport Target - Game On | 572 – Ruins of Lordaeron | fehlt/unvollständig | 1274.51 / 1774.68 / 27.04 | 1286.57 / 1667.40 / 39.60 |
| 4933 | Arena - Undercity - Teleport Target - Game On | 572 – Ruins of Lordaeron | fehlt/unvollständig | 1246.06 / 1743.62 / 40.00 | 1286.57 / 1667.40 / 39.60 |
| 4934 | Arena - Undercity - Teleport Target - Game On | 572 – Ruins of Lordaeron | fehlt/unvollständig | 1305.44 / 1754.14 / 40.00 | 1286.57 / 1667.40 / 39.60 |
| 4935 | Arena - Undercity - Teleport Target - Game On | 572 – Ruins of Lordaeron | fehlt/unvollständig | 1321.62 / 1586.98 / 40.00 | 1286.57 / 1667.40 / 39.60 |
| 4936 | Arena - Undercity - Teleport Target - Game On | 572 – Ruins of Lordaeron | fehlt/unvollständig | 1269.12 / 1579.99 / 40.00 | 1286.57 / 1667.40 / 39.60 |
| 4941 | Arena - Undercity - Teleport Target - Game On | 572 – Ruins of Lordaeron | fehlt/unvollständig | 1285.36 / 1667.68 / 10.00 | 1286.57 / 1667.40 / 39.60 |
| 4944 | Arena - Bladesedge - Teleport Target - Game On | 562 – Blade's Edge Arena | fehlt/unvollständig | 6237.84 / 261.36 / -20.00 | 6238.89 / 264.15 / 0.89 |
| 5127 | Ebon Hold (E.K.) Top Return Target | 0 – Eastern Kingdoms | fehlt/unvollständig | 2479.34 / -5495.73 / 421.84 | 2462.76 / -5598.19 / 550.22 |
| 5128 | Ebon Hold (E.K.) Top West Target | 0 – Eastern Kingdoms | fehlt/unvollständig | 2469.29 / -5545.84 / 546.10 | 2472.67 / -5530.10 / 420.64 |
| 5129 | Ebon Hold (E.K.) Top Return Target | 0 – Eastern Kingdoms | fehlt/unvollständig | 2362.55 / -5574.54 / 421.84 | 2462.76 / -5598.19 / 550.22 |
| 5130 | Ebon Hold (E.K.) Top South Target | 0 – Eastern Kingdoms | fehlt/unvollständig | 2412.68 / -5584.65 / 546.10 | 2397.37 / -5581.06 / 420.64 |
| 5131 | Ebon Hold (E.K.) Top Return Target | 0 – Eastern Kingdoms | fehlt/unvollständig | 2558.37 / -5612.56 / 421.83 | 2462.76 / -5598.19 / 550.22 |
| 5132 | Ebon Hold (E.K.) Top North Target | 0 – Eastern Kingdoms | fehlt/unvollständig | 2508.14 / -5602.50 / 546.10 | 2522.45 / -5605.70 / 420.64 |
| 5133 | Ebon Hold (E.K.) Top Return Target | 0 – Eastern Kingdoms | fehlt/unvollständig | 2441.37 / -5691.42 / 421.83 | 2462.76 / -5598.19 / 550.22 |
| 5134 | Ebon Hold (E.K.) Top East Target | 0 – Eastern Kingdoms | fehlt/unvollständig | 2451.50 / -5641.27 / 546.10 | 2448.02 / -5655.82 / 420.64 |
| 5135 | Ebon Hold (E.K.) Middle -> Bottom Target | 0 – Eastern Kingdoms | fehlt/unvollständig | 2427.67 / -5548.64 / 542.09 | 2435.77 / -5610.34 / 366.82 |
| 5136 | Ebon Hold (E.K.) Middle -> Bottom Target | 0 – Eastern Kingdoms | fehlt/unvollständig | 2505.39 / -5563.90 / 542.09 | 2435.77 / -5610.34 / 366.82 |
| 5137 | Ebon Hold (E.K.) Bottom -> Middle Target | 0 – Eastern Kingdoms | fehlt/unvollständig | 2419.03 / -5622.17 / 542.07 | 2472.90 / -5530.64 / 420.64 |
| 5138 | Ebon Hold (E.K.) Bottom -> Ground Target | 0 – Eastern Kingdoms | fehlt/unvollständig | 2486.99 / -5636.42 / 542.14 | 2415.16 / -5733.04 / 153.92 |
| 5273 | Dalaran Well teleporter | 571 – Northrend | fehlt/unvollständig | 5886.27 / 651.45 / 640.00 | 5878.56 / 666.42 / 615.29 |
| 5326 | Arena - Dalaran Arena - Teleport Target | 617 – Dalaran Sewers | fehlt/unvollständig | 1330.00 / 800.00 / -10.00 | 1291.57 / 792.97 / 8.11 |
| 5328 | Arena - Dalaran Arena - Teleport Target | 617 – Dalaran Sewers | fehlt/unvollständig | 1289.72 / 736.61 / 3.16 | 1291.57 / 792.97 / 8.11 |
| 5329 | Arena - Dalaran Arena - Teleport Target | 617 – Dalaran Sewers | fehlt/unvollständig | 1292.05 / 845.27 / 5.63 | 1291.57 / 792.97 / 8.11 |
| 5330 | Arena - Dalaran Arena - Teleport Target | 617 – Dalaran Sewers | fehlt/unvollständig | 1238.08 / 780.11 / 3.16 | 1291.57 / 792.97 / 8.11 |
| 5331 | Arena - Dalaran Arena - Teleport Target | 617 – Dalaran Sewers | fehlt/unvollständig | 1348.50 / 792.83 / 3.82 | 1291.57 / 792.97 / 8.11 |
| 5447 | Arena - Orgrimmar - Teleport Target 3 | 618 – The Ring of Valor | fehlt/unvollständig | 801.66 / -278.86 / -24.00 | 763.37 / -284.29 / 28.28 |
| 5740 | Halls of Reflection (The Impenetrable Door) | 668 – Halls of Reflection | fehlt/unvollständig | 5361.10 / 2061.02 / 707.70 | 5354.01 / 2053.53 / 707.70 |
| 5879 | Firelands Forgeworks (Exit) | 1 – Kalimdor | fehlt/unvollständig | 5042.17 / -2026.51 / 1148.98 | 5036.41 / -2045.99 / 1368.39 |
| 5880 | Firelands Forgeworks (Enterance) | 1 – Kalimdor | fehlt/unvollständig | 5030.84 / -2036.30 / 1378.09 | 5029.45 / -2029.36 / 1148.97 |
| 5893 | Hyjal - Teleport from Seat of the Prophets | 1 – Kalimdor | fehlt/unvollständig | 4320.92 / -3288.20 / 1036.05 | 3948.62 / -2818.22 / 618.75 |
| 5895 | Hyjal - Teleport From Sulfuron Spire | 1 – Kalimdor | fehlt/unvollständig | 3943.90 / -2807.49 / 618.75 | 4316.05 / -3282.51 / 1035.53 |
| 5926 | The Lost Isles - Volcanoth's Lair - Teleport Target - Phase 8+ | 659 – Lost Isles Volcano Eruption | fehlt/unvollständig | 1429.99 / 1233.86 / 124.60 | 1172.59 / 1094.79 / 119.23 |
| 5931 | The Crucible of Flame (Enterance) | 1 – Kalimdor | fehlt/unvollständig | 4664.00 / -3686.81 / 956.63 | 4679.10 / -3675.53 / 696.45 |
| 5937 | The Crucible of Flame (Exit) | 1 – Kalimdor | fehlt/unvollständig | 4678.15 / -3682.77 / 696.45 | 4653.99 / -3688.44 / 955.26 |
| 6321 | Badlands 4.x - Cliff Teleport Target | 0 – Eastern Kingdoms | fehlt/unvollständig | -6518.68 / -4604.42 / 29.23 | -6547.88 / -4443.24 / 332.08 |
| 6322 | Badlands 4.x - Dock Teleport Target | 0 – Eastern Kingdoms | fehlt/unvollständig | -6551.28 / -4446.46 / 332.83 | -6519.82 / -4608.52 / 28.48 |
Die Namen stammen aus der Teleporttabelle. Sie belegen keine universelle Begehbarkeit oder Freigabe für alle Spieler. Quests, Fraktion, Instanzstatus und Skriptbedingungen müssen vor einem Import geprüft werden.
Maschinenlesbare Daten einschließlich des vollständigen Node-/Kantenstatus: [same-map-portals.json](same-map-portals.json).
+107
View File
@@ -0,0 +1,107 @@
# Erste Schiffsverbindung: Sturmwind – Rut’theran
Auswahl des Nutzers: Schiff ab Sturmwind. Erster Test mit **The Bravery**,
Spawn **2**, Entry **176310**. Die Datenbankbeschreibung nennt noch Auberdine;
die tatsächlichen nativen Halte liegen bei Sturmwind und südlich des
Rut’theran-Teleportziels. Für die Aufnahmen ist der echte Anleger maßgeblich.
Live über `clientless transport services 2` geprüft:
| Halt | Karte | Fahrzeugursprung X / Y / Z | Haltezeit |
| --- | --- | --- | --- |
| Sturmwind | 0 | -8650.719 / 1346.051 / 0.000 | 60 s |
| Kalimdor, Anleger bei Rut’theran | 1 | 8162.587 / 1005.365 / 0.000 | 60 s |
Umlaufzeit: 235782 ms, zwei Halte und zwei native Pfadabschnitte. Diese
Koordinaten bezeichnen den Schiffsrumpf-Ursprung, nicht den Groundnode.
## Aufnahme in Sturmwind
Am festen Boden unmittelbar vor dem Übergang zum Schiff positionieren:
```text
.clientless node add ship_sw_br ship
.clientless transport near 50
```
Bei frisch angedocktem Schiff Spawn 2 beginnen, normal an Bord zu einem
sicheren ebenen Deckplatz laufen und dort speichern:
```text
.clientless transport record start ship_sw_br 2
.clientless transport record stop bravery_center
```
## Aufnahme auf Kalimdor
Mit demselben Schiff mitfahren. Am Ziel aussteigen und direkt vor dem
Übergang auf festem Boden den gemeinsamen Haltestellen-Node erstellen:
```text
.clientless node add ship_ruttheran_br ship
```
Während eines vollständigen Halts desselben Schiffs erneut an Bord und zum
gleichen lokalen Stehplatz gehen:
```text
.clientless transport record start ship_ruttheran_br 2
.clientless transport record stop bravery_center
```
Danach Aufnahmen prüfen und Verbindung anlegen:
```text
.clientless transport stops
.clientless transport link ship_sw_br ship_ruttheran_br ship 100 both
```
Der vorläufige Graphkostenwert 100 dient dem isolierten Transporttest. Erst
nach funktioneller Abnahme und Kostenkalibrierung konkurrierende Fernrouten
bewerten. Die Landanschlüsse zum restlichen Graphen separat aufnehmen;
keine zusätzlichen Ein-/Ausgangsnodes für denselben Anleger erstellen.
## Abnahmekriterien
Nach zwei kompatiblen echten Übergangsaufnahmen Arthoran an `ship_sw_br`
positionieren und `travel_to_node` nach `ship_ruttheran_br` ausführen. Prüfen:
Warten, Boarding, Passenger, nativer Kartenwechsel 0→1 mit vollständigem
Clientless-ACK, neue Transportkopie bei identischer Spawn-ID, Ausstieg,
Landnode erreicht. Danach Gegenrichtung sowie Anschluss an Groundroute.
Die generische Typ-15-Unterstützung und der zusätzliche Regressionstest für
den Kartenwechsel sind vorhanden. Stand der echten Schiffsfahrten siehe
Live-Prüfung unten. Danach folgt der Zeppelin; Fahrstühle sind zurückgestellt.
## Live-Prüfung am 26.10.2026
Beide Übergänge wurden durch den GM aufgenommen und geladen:
- `ship_sw_br`: 11 Punkte, 17,72 Yards, Karte 0.
- `ship_ruttheran_br`: 4 Punkte, 7,76 Yards, Karte 1. Node-Typ bei Anlage
`ground`; der Executor verwendet den Fahrtkantentyp `ship`.
- Beide Spawn 2, Entry 176310 und Stand `bravery_center`.
Die native Kompatibilitätsprüfung bestand; bidirektionale Kante 1743 wurde
angelegt. Hinfahrt ohne Recovery: Waiting 09:15:04, Boarding 09:16:26,
Riding/Passenger 09:16:31, Leaving 09:18:23, Arrived auf Land 09:18:25.
Die nächste Planung begann nachweislich auf Karte 1.
Rückfahrt wurde ohne Logout per `.clientless luareload 26` um 09:32:29
gestartet. Der Planer wählte Kante 1743 mit `reverse=1`. Boarding 09:34:06,
Riding/Passenger 09:34:07, Leaving 09:36:05, Arrived auf Land in Sturmwind
09:36:08. Auch die Rückfahrt wurde ohne Recovery abgeschlossen. Beide
Richtungen nutzten die normalen Schiffs- und Passenger-Mechaniken inklusive
Kartenwechsel. Es wurden während der Reisen keine Charakterpositionen als
Test-Abkürzung manipuliert.
Worldserver-PID 37385 blieb während beider Fahrten unverändert. Arthoran
blieb nach Abschluss online am Sturmwind-Anleger. Das Hinreise-Lua wurde
auf der Festplatte wiederhergestellt, ohne eine weitere Aktion auszuführen.
Die separat vom GM aufgezeichneten Land-/Portalanschlüsse Richtung Darnassus
sind noch nicht Bestandteil dieses isolierten Seestrecken-Tests.
Ausgangsposition und vorheriges Lua liegen unter
`/tmp/vcore-ship-ride.voj3k_8t`. Vorwärts-Lua ebenfalls dort gesichert.
Der Nutzer untersagte für diese Testphase Shutdown und Neustart bis zu einer
ausdrücklichen Freigabe; der laufende Server wurde durchgehend beibehalten.
+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.
@@ -0,0 +1,78 @@
# Allgemeiner Lifecycle animierter GameObject-Transporte
## Ursache
Die Tiefenbahn besteht aus sechs `GAMEOBJECT_TYPE_TRANSPORT`-GameObjects
(Typ 11). Drei Wagen besitzen ihre Datenbank-Spawnposition in Eisenschmiede,
drei in Sturmwind. Diese GameObjects werden beim Erstellen bereits über
`setActive(true)` für Aktualisierung und einen Unload-Lock ihres Spawn-Grids
registriert. Vorher müssen sie jedoch vom normalen Grid-Loader erzeugt werden.
Bei ausschließlich in Sturmwind anwesenden Spielern blieb das entfernte
Spawn-Grid ungeladen. Der dort startende Zug existierte daher serverseitig noch
nicht. Das betrifft Netzwerkspieler ebenso wie clientlose Spieler. Ein Besuch
in Eisenschmiede lud die fehlenden Wagen und ließ den Fehler bis zum nächsten
Neustart verschwinden.
## Änderung
`ObjectMgr::LoadGameObjects` indexiert nach Abschluss der vorhandenen
Validierungen reguläre Typ-11-Spawns nach Karte. Ereignisgebundene Spawns
bleiben außerhalb dieses Index und behalten die bisherige Ereignisverwaltung.
`MapManager` ruft nach Initialisierung der Respawn-Daten beziehungsweise der
Instanz-/Battleground-Daten `Map::LoadTransportGameObjectGrids` auf. Diese
Funktion lädt ausschließlich die Spawn-Grids der indexierten Transporte für die
jeweilige Karte und Spawnmaske. Das Laden erfolgt über `EnsureGridLoaded`:
vorhandene Pools, Spawn-Gruppen, Respawn- und Phasenmechaniken gelten weiter.
Bereits geladene Grids erzeugen keine zusätzlichen Objektinstanzen.
Die Änderung verwendet keine CPC-, AdventureBot- oder TravelGraph-Komponenten.
Die getrennt verwalteten Typ-15-Transporte (Schiffe/Zeppeline) bleiben im
vorhandenen Transport-Lifecycle. Es werden keine Transportfahrpläne verändert.
Im Testdatenbestand liegen zwischen 1 und 47 Typ-11-Spawns pro betroffener
Karte, sechs davon auf Karte 369. Benachbarte Wagen teilen sich Spawn-Grids.
Durch das frühe Laden werden auch die normalen Inhalte dieser Grids früher
erzeugt; ein vollständiges Vorladen sämtlicher Kartengrids erfolgt nicht.
## Prüfung
`python3 tests/clientless/test_transport_grid_loading.py` prüft die tatsächlichen
neuen Indexierungs- und Ladeblöcke mit deterministischen Core-Doubles:
beide Stationen ohne Spieler, wiederholtes Laden, andere Karten,
Spawnmasken, entfernte/verschobene Spawns und Ausschluss ereignisgebundener
Objekte sowie Typ-15-Transporte.
Für den entscheidenden Live-Test ist ein frischer Worldserverprozess nötig.
Danach darf zunächst ausschließlich die Sturmwindseite betreten werden.
Alle sechs Spawns 18802–18807 müssen dort bereits über `transport inspect`
auflösbar sein. Zwei zeitlich getrennte Zustandsaufnahmen müssen Bewegung
beider Zuggruppen zeigen. Das erstmalige manuelle Betreten der Gegenseite
wäre für diesen Test eine unzulässige Vorbedingung, da es den ursprünglichen
Fehler verdecken würde.
## Live-Ergebnis, 26.10.2026
Build und Installation bestanden, Build-ID
`28fa55f1b9604a57da4616b23a195c117d2fcc82`. Neuer Worldserverprozess: PID 36683;
Ready-Meldung und Port 8085 geprüft. Vor dem Test war kein GM eingeloggt.
Arthoran wurde ausschließlich an `tram_sw` mit einem untätigen Lua-Skript
eingeloggt; ein Besuch am anderen Ende entfiel vollständig.
Die neue Core-Diagnostik meldete für Karte 369 drei geladene Spawn-Grids und
sechs Transportkandidaten. Alle sechs Spawns 18802–18807 waren auflösbar.
Zwei mindestens 20 Sekunden getrennte Aufnahmen bestätigten für jeden Wagen
geänderte Position und Animationsfortschritt. Beide Zuggruppen bewegten sich.
Ergebnis und Konsolenausgaben:
`/tmp/vcore-tram-ride.1i65g2dm/cold-grid-test-result.json` und
`cold-grid-test-console.txt` im selben Verzeichnis. Das vorherige Reise-Lua wurde
anschließend wiederhergestellt; Arthoran blieb online und untätig in Sturmwind.
Eine visuelle Abnahme durch einen normalen WoW-Client bleibt Aufgabe des GM.
Separater offener Befund: Beim vorhergehenden Shutdown des noch unveränderten
Servers (PID 30922, alter Build) erschien `Object.h:448 in GetMap ASSERTION FAILED:
m_currMap`. Der Prozess endete. Diese Assertion trat vor Installation des
Grid-Fixes auf; ihre Ursache ist noch nicht festgestellt. Vorherige Logs liegen
unter `before-grid-fix-restart` im obigen Testverzeichnis. Die Konsolenmeldung
enthielt keinen auswertbaren Stacktrace.
+330
View File
@@ -0,0 +1,330 @@
# Clientless Transport: Aufnahme und Test
Die Integration verwendet reguläre Trinity-Transporte (`TransportBase`). Eine
Haltestelle hat genau einen TravelGraph-Node für Ein- und Ausstieg. Der Übergang
auf das Fahrzeug wird getrennt von den Groundkanten aufgenommen. Es werden
keine Schiffe, Aufzüge oder Bahnen teleportiert oder für den Bot angehalten.
## Datenbank
Die zusätzliche Migration liegt unter
`sql/custom/world/2026_10_25_00_clientless_transport.sql`. Sie wird ausdrücklich
auf die Test-Weltdatenbank angewendet. Die drei neuen Tabellen sind:
- `vcore_clientless_transport_stop`: Haltestelle, Fahrzeug-Entry, Spawn und Dockpose.
- `vcore_clientless_transport_point`: geordnete Übergangspunkte mit Welt-/Transportbezug.
- `vcore_clientless_transport_stand`: benannte Stehplätze relativ zum Fahrzeugmodell.
Bestehende Nodes und Groundkanten bleiben erhalten. Neue Fahrtverbindungen
werden in `vcore_clientless_travel_edge` gespeichert. Ohne zwei gültige,
kompatible Aufnahmen wird eine Transportkante vom Graphen nicht geladen.
Ein Reload kopiert keine Änderungen in eine bereits laufende Fahrt hinein.
## 1. Haltestelle und Fahrzeug bestimmen
Schiffe und Zeppeline können unabhängig vom Aufenthaltsort des GM über die
nativen Routendaten untersucht werden:
```text
.clientless transport services
.clientless transport services 7
```
Die Liste zeigt Spawn, Entry, Modellname, Umlaufzeit, Zahl der geplanten Halte
und Pfadabschnitte. Mit Spawn-ID werden zusätzlich die tatsächlichen
Dockpositionen, Karten, Ankunftszeitpunkte im Umlauf und Haltezeiten ausgegeben.
Diese Daten stammen aus den geladenen Transportpfaden, nicht aus möglicherweise
veralteten `transports.name`-Beschreibungen. Die Dockposition bezeichnet den
Fahrzeugursprung; den Haltestellen-Node weiterhin auf begehbarem festen Boden
am tatsächlichen Land-Deck-Übergang aufnehmen. Reine Patrouillen ohne planmäßige
Halte sind keine automatisch geeigneten Passagierverbindungen.
Beide Befehle sind auch ohne eingeloggten Bot in der Konsole verfügbar.
Die Spawn-ID bleibt auf beiden Karten dieselbe, obwohl der Core beim
Kartenwechsel eine andere Transportkopie verwendet. Diese kopiert der Recorder
nicht als zweites Fahrzeug in den Graphen.
Mit dem GM auf festem Boden direkt vor der gewünschten Einstiegsstelle stehen:
```text
.clientless node add lift_lower elevator
.clientless transport near 30
```
Für andere Verkehrsmittel den Nodetyp `tram`, `ship` oder `zeppelin` verwenden.
`near` zeigt unter anderem Entry, Spawn-ID, Weltposition, `stopped`, Periode,
Fortschritt und verbleibende Haltezeit. Die **Spawn-ID**, nicht die Entry-ID,
bindet die Aufnahme an das konkrete Fahrzeug bzw. den konkreten Wagen.
```text
.clientless transport inspect 204666
```
In der Konsole wird ein online befindlicher CPC-Player als Beobachter angegeben:
```text
clientless transport inspect 204666 26
clientless transport near 30 26
```
Die vom Transportrecorder verwendeten Node-Koordinaten können auch über die
Konsole geprüft werden:
```text
clientless transport node tram_sw
clientless transport node tram_if
```
Das Objekt muss auf der Map des Beobachters geladen sein. `remainingPause` wird
in Millisekunden ausgegeben; `4294967295` bezeichnet einen Halt ohne bekannte
zeitliche Begrenzung. Ein GoState allein wird nicht als Ankunft interpretiert.
## 2. Geeigneten Stehplatz speichern (optional vorab)
Normal an Bord gehen, einen sicheren, ebenen Platz wählen und dort stehen:
```text
.clientless transport stand add center
.clientless transport stand list
```
Stehplätze sind lokal zum Transportmodell gespeichert und bleiben bei Bewegung
und Kartenwechseln an derselben Stelle des Fahrzeugs. Sie können auch während
der Fahrt aufgenommen werden. Nicht fliegen oder fallen. Derselbe Name wird
nicht still überschrieben. Andere Namen erlauben weitere Standorte.
```text
.clientless transport stand list 4170
.clientless transport stand remove 4170 unused_name
```
`list <entry>` und `remove <entry> <name>` sind konsolenfähig. Ein von einer
Haltestelle verwendeter Standname kann nicht entfernt werden, bevor deren
Aufnahme ausdrücklich entfernt wurde.
## 3. Land -> Deck aufnehmen
Wieder am gemeinsamen Haltestellen-Node auf festem Boden stehen. Auf die nächste
Ankunft warten, damit ein vollständiges Haltefenster verfügbar ist:
```text
.clientless transport record start lift_lower 204666
```
Nun normal zum Fahrzeug gehen, es betreten und bis zum gewünschten Stehplatz
laufen. Die Aufnahme erfolgt automatisch alle 100 ms mit mindestens 0,75 Yards
Punktabstand. Der Wechsel vom Land zum Passenger wird automatisch markiert.
```text
.clientless transport record mark
.clientless transport record info
.clientless transport record stop center
```
`mark` ist optional und erzwingt einen zusätzlichen Punkt. `stop center`
speichert den Übergang und legt bei Bedarf den Stand `center` an. Existiert er
bereits, muss der GM höchstens 1,5 Yards davon entfernt stehen.
Die Aufnahme muss während desselben Halts beendet werden. Abfahrt,
Transportwechsel, Verlassen des Fahrzeugs nach bereits erfolgtem Einstieg,
Positionssprünge über 8 Yards, Flug oder ein Übergang über 60 Yards machen sie
ungültig. Vorhandene Aufnahmen werden nicht still überschrieben.
```text
.clientless transport record cancel
```
Bei einem Fehler bleibt die Diagnose über `record info` sichtbar. Erst mit
`cancel` verwerfen und beim nächsten Halt neu beginnen. Jeder GM hat einen
eigenen Aufnahmebuffer; Logout verwirft nur dessen ungespeicherte Aufnahme.
## 4. Gegenhaltestelle aufnehmen
Mit genau demselben Fahrzeug/Wagen zur Gegenhaltestelle fahren. Aussteigen und
auf festem Boden den gemeinsamen Node anlegen:
```text
.clientless node add lift_upper elevator
```
Bei einer folgenden Ankunft von diesem Node erneut an Bord zum gleichen lokalen
Stand laufen und aufnehmen:
```text
.clientless transport record start lift_upper 204666
.clientless transport record stop center
.clientless transport stops
```
Die Deck-Endpunkte beider Übergänge dürfen höchstens 3 Yards auseinanderliegen.
Damit wird keine unbekannte längere Querung des Decks zwischen zwei verschiedenen
Standorten benötigt. Der aufgezeichnete Übergang wird beim Ausstieg umgekehrt.
Deshalb einen Weg aufnehmen, der in beiden Laufrichtungen begehbar ist.
Bei einer Tiefenbahnaufnahme denselben Wagen an beiden Bahnhöfen verwenden.
Die sechs Wagen haben Entries 176080..176085 und Spawn-IDs 18802..18807.
Die Haltefenster betragen ungefähr 11..14 Sekunden; kurze Rampenwege sind
entsprechend wichtig. Ein anderer Wagen ist eine andere Aufnahmeverbindung.
## 5. Fahrtkante anlegen
```text
.clientless transport link lift_lower lift_upper elevator 100 both
```
Der Kostenwert ist ein positiver **Graphkostenwert**, keine automatisch mit
Groundlängen vergleichbare Sekundenzahl. Zum ersten isolierten Funktionstest
ist der genaue Wert unkritisch; konkurrierende Fernrouten benötigen später eine
gemeinsame Kostenkalibrierung. `both` ist optional und erzeugt eine bidirektionale
Kante. Es darf nur verwendet werden, wenn das reale Verkehrsmittel beide
Richtungen bedient. Ohne `both` entsteht nur die angegebene Fahrtrichtung.
Die Kante wird erst nach Prüfung beider Haltestellen angelegt. Gleicher Entry,
gleicher Spawn, verschiedene Dockpositionen/Maps und kompatible Stehplätze sind
notwendig. Die Typen `tram`/`elevator` gehören zu GameObjects des Typs 11,
`ship`/`zeppelin` zu Typ 15.
Die Haltestellen benötigen wie andere Nodes eine Verbindung zum übrigen Netz:
aufgenommene Groundkanten oder zulässige, vollständig geprüfte MMAP-Anschlüsse.
## 6. Botfahrt testen
Arthoran zunächst an der Abfahrt positionieren und clientlos einloggen. Das
bestehende Login-Lua-Skript kann bereits andere Aufträge starten; für einen
isolierten Transporttest dieses Skript vorher entsprechend einstellen, z.B.:
```lua
queue_action("travel_to_node", {
node = "lift_upper",
hard_destination = true
})
```
Alternativ bei einem untätigen CPC-Player über Konsole oder Spielchat:
```text
clientless transport travel 26 lift_upper
clientless transport status 26
clientless actioninfo 26
```
`travel` hängt einen Auftrag an die reguläre CPC-Aktionswarteschlange an; es
unterbricht keine bereits laufende andere Aktion. Die Zustände sind `Waiting`,
`Boarding`, `Riding`, `Leaving`, `Arrived`, `Failed`. Während der Mitfahrt wird
keine Groundnavigation gestartet. Native Kartenwechsel werden für CPC ohne
Client-ACK abgeschlossen. Der nächste Graphschritt beginnt erst nach dem
Aussteigen und Erreichen des gemeinsamen Zielnodes auf Land.
```text
clientless transport cancel 26
```
Abbruch auf Land stoppt die Fahrt. An Bord bleibt der Bot Passenger und steigt
an einer sicheren aufgezeichneten Haltestelle aus, statt in der Luft oder im
Wasser abgehängt zu werden. Ein harter Timeout meldet `Failed` und lässt eine
noch vorhandene Passenger-Zuordnung bestehen; die Diagnose bleibt erhalten.
Alle Transport-Zustandswechsel und Aufnahmefehler stehen in
`adventurebot-navigation.log`. `.clientless`-Spielchatbefehle und deren Antworten
werden dort ebenfalls protokolliert; Konsolenantworten sind über tmux lesbar.
## Pflege
```text
.clientless transport reload
.clientless transport forget lift_lower
```
`forget` entfernt nur die Übergangsaufnahme. Node, Groundkanten und benannte
Stehplätze bleiben bestehen. Nach Verschieben des Nodes dessen Aufnahme neu
anlegen. Fahrtkanten mit unvollständigen Daten werden nicht geplant.
## Verifikation und Grenzen
Automatisierte Zustands-/Recorder-Tests:
```text
python3 tests/clientless/test_transport.py
```
Diese verwenden die tatsächlichen Produktionsmethoden mit deterministischen
Weltobjekt-Doubles. Sie prüfen unter anderem Haltefenster, Koordinatenrahmen,
Warteverhalten, Passenger-Zuordnung, Ausstieg und Abbruch/Timeout. Sie beweisen
keine Kollisionsfreiheit eines konkreten Decks und keine erfolgreiche Fahrt
auf einem realen GameObject.
Nach dem Tiefenbahntest folgen ein Schiff mit Kartenwechsel und ein Zeppelin;
Fahrstühle werden anschließend behandelt. Je Verbindung beide Fahrtrichtungen,
verpasste Halte, Ein-/Ausstieg und Fortsetzung einer Groundroute prüfen. Logout/Serverneustart
während einer Typ-11-Fahrt ist noch separat zu verifizieren; der bestehende
Trinity-Loginpfad stellt vorrangig die Typ-15-Transportzuordnung wieder her.
Die Recorder setzen einen normalen GM-Client für die echte Bewegung voraus;
sie erzeugen keine synthetischen Deckpfade.
## Live-Test Sturmwind → Eisenschmiede, 26.10.2026
Arthoran (GUID 26) fuhr über Kante 1741 von `tram_sw` nach `tram_if` auf
Wagen 18803. Navigationlog: Boarding 00:37:34, Riding mit `passenger=1`
00:37:35, Leaving 00:38:38, Arrived mit `passenger=0` 00:38:42.
Die Konsole meldete anschließend `travel-route-complete`. Das belegt die
serverseitige Durchführung einer Richtung; Gegenrichtung und visuelle
Ingame-Abnahme sind damit noch nicht bestätigt.
Vorbedingung/noch offener Fehler: Beim ersten Start ausschließlich an der
Sturmwindseite war Spawn 18803 nicht geladen. Seine Datenbank-Spawnposition
liegt auf der Eisenschmiedeseite (4.528, 8.435, 7.011). Ein kurzer Diagnose-Login
Arthorans an `tram_if` lud das Spawn-Grid; danach war der aktive Wagen auch von
Sturmwind aus verfügbar. Für den eigentlichen Fahrtest wurde Arthoran offline
wieder an `tram_sw` positioniert. Während der Fahrt wurden keine Positionen per
Datenbank oder Teleport verändert. Automatisches Laden des entfernten
Transport-Spawn-Grids muss vor autonomem Betrieb ergänzt werden; der erfolgreiche
Fahrtest nach manuellem Vorladen behebt diese Einschränkung nicht.
Testskript und vorherige Charakterposition gesichert unter
`/tmp/vcore-tram-ride.1i65g2dm`.
Nachtrag: Der oben beschriebene Spawn-Grid-Fehler wurde anschließend im
allgemeinen Map-Lifecycle behoben und mit einem frischen Prozess ausschließlich
von Sturmwind aus geprüft. Alle sechs Wagen waren verfügbar und bewegten sich.
Details und verbleibende Befunde: [transport-grid-lifecycle.md](transport-grid-lifecycle.md).
## Nächste Verbindungen: Schiff und Zeppelin
Aktuelle Nutzerauswahl: zuerst **Schiff ab Sturmwind**. Die konkrete Aufnahme
für Spawn 2 und der Testablauf stehen in
[ship-stormwind-ruttheran.md](ship-stormwind-ruttheran.md).
Geeignete erste Kandidaten aus der Testdatenbank:
- Spawn 7, Entry 20808, `The Maiden's Fancy`: Küstenschiff; tatsächliche Häfen
über `services 7` prüfen. Der Kartenwechsel zwischen Kalimdor und den östlichen
Königreichen prüft die Passenger-Übergabe zwischen Transportkopien.
- Spawn 20, Entry 190549, `The Zephyr`: Zeppelin auf Kalimdor. Die vorhandene
Transport-/SmartAI bleibt erhalten. Die Fahrt ergänzt den Lufttransport-Test
ohne Kartenwechsel.
Die Recorder- und Link-Befehle bleiben dieselben. Beispiele mit nach Prüfung
der Anlegestellen manuell aufgenommenen Nodes:
```text
.clientless node add ship_ratchet ship
.clientless transport record start ship_ratchet 7
.clientless transport record stop maiden_center
.clientless node add ship_booty_bay ship
.clientless transport record start ship_booty_bay 7
.clientless transport record stop maiden_center
.clientless transport link ship_ratchet ship_booty_bay ship 100 both
```
Den ersten Stehplatz an der Gegenhaltestelle wieder exakt aufsuchen. Zwischen
den beiden Node-Aufnahmen mit demselben Schiff mitfahren. `record start` beginnt
jeweils auf festem Boden, `record stop` endet an Bord während desselben Halts.
Pro physischer Anlegestelle weiterhin nur einen gemeinsamen Node verwenden;
Landverbindungen zum übrigen TravelGraph getrennt als Groundkante erfassen.
Für den Zeppelin analog eigene Haltestellen-Keys, Fahrzeug-Spawn 20 und
Kantentyp `zeppelin` verwenden. Schiffs- und Zeppelin-Verbindungen erst nach
einer echten Fahrt als verifiziert betrachten. Die automatisierten Tests
simulieren den nativen Kartenwechsel; sie ersetzen keinen echten Worldport-
und Kollisions-/Sichtbarkeitstest.
+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).
+4
View File
@@ -1 +1,5 @@
*.sql
!2026_10_25_00_clientless_transport.sql
!2026_10_26_00_clientless_disable_taxi_585.sql
!2026_10_26_01_clientless_stormwind_mage_tower.sql
!2026_10_26_02_clientless_portal_use.sql
@@ -0,0 +1,27 @@
-- Vcore CPC: one shared embark/disembark node per physical stop.
-- Apply explicitly to the dedicated test world database. No routes are seeded.
CREATE TABLE IF NOT EXISTS vcore_clientless_transport_stop (
node_key varchar(100) NOT NULL,
transport_entry int unsigned NOT NULL,
transport_spawn int unsigned NOT NULL,
map int unsigned NOT NULL,
dock_x float NOT NULL, dock_y float NOT NULL, dock_z float NOT NULL, dock_o float NOT NULL,
period_ms int unsigned NOT NULL,
stand_name varchar(100) NOT NULL,
PRIMARY KEY (node_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS vcore_clientless_transport_point (
node_key varchar(100) NOT NULL,
point_index int unsigned NOT NULL,
on_transport tinyint unsigned NOT NULL,
x float NOT NULL, y float NOT NULL, z float NOT NULL, o float NOT NULL,
PRIMARY KEY (node_key,point_index)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS vcore_clientless_transport_stand (
transport_entry int unsigned NOT NULL,
name varchar(100) NOT NULL,
x float NOT NULL, y float NOT NULL, z float NOT NULL, o float NOT NULL,
PRIMARY KEY (transport_entry,name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -0,0 +1,11 @@
-- Explicit test-TravelGraph correction. No changes to WoW taxi definitions/NPCs.
START TRANSACTION;
UPDATE vcore_clientless_travel_node
SET enabled=0,comment='Disabled: taxi 585 is not a regular player flight point (GM verified in Ruttheran)'
WHERE node_key='taxi_585';
UPDATE vcore_clientless_world_point
SET enabled=0,comment='Disabled: taxi 585 is not a regular player flight point (GM verified in Ruttheran)'
WHERE point_key='taxi_585';
UPDATE vcore_clientless_travel_edge SET enabled=0
WHERE from_node_key='taxi_585' OR to_node_key='taxi_585';
COMMIT;
@@ -0,0 +1,18 @@
-- Explicit local test-graph calibration; first run these worldserver commands:
-- clientless portal import 702
-- clientless portal import 704
-- The importer seeds source nodes at the native AreaTrigger box centers.
-- These centers are above the walkable floor. Keep their X/Y and move only
-- the graph approach Z inside the native box, using the opposite arrival Z.
-- Native areatrigger_teleport and DBC data are deliberately untouched.
-- Reapply after reimporting 702/704, then reload the travel graph.
START TRANSACTION;
UPDATE vcore_clientless_world_point
SET position_z=29.6207,
comment='Mage tower 702 walkable approach inside native trigger; calibrated 2026-10-26'
WHERE point_key='portal_at_702_source' AND map=0;
UPDATE vcore_clientless_world_point
SET position_z=148.616,
comment='Mage tower 704 walkable approach inside native trigger; calibrated 2026-10-26'
WHERE point_key='portal_at_704_source' AND map=0;
COMMIT;
@@ -0,0 +1,10 @@
-- Apply explicitly to the dedicated test world DB. No portals are auto-enabled.
-- A directed portal_use edge binds to one static clickable GameObject spawn.
-- Entry and spell identify stale bindings after native portal data changes.
CREATE TABLE IF NOT EXISTS vcore_clientless_portal_use (
edge_id int unsigned NOT NULL,
gameobject_spawn_id int unsigned NOT NULL,
gameobject_entry int unsigned NOT NULL,
spell_id int unsigned NOT NULL,
PRIMARY KEY (edge_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+11
View File
@@ -85,3 +85,14 @@ endif()
if(USE_COREPCH)
add_cxx_pch(game ${PRIVATE_PCH_HEADER})
endif()
# Vcore C7: server-side Lua runtime for clientless Player controllers.
find_package(Lua 5.4 REQUIRED)
target_include_directories(game PRIVATE ${LUA_INCLUDE_DIR})
target_link_libraries(game PRIVATE ${LUA_LIBRARIES})
install(
DIRECTORY "${CMAKE_SOURCE_DIR}/src/server/scripts/Custom/clientless/"
DESTINATION "bin/scripts/clientless"
FILES_MATCHING PATTERN "*.lua"
)
+16
View File
@@ -198,6 +198,9 @@ void ChatHandler::SendSysMessage(const char *str, bool escapeCharacters)
while (char* line = LineFromMessage(pos))
{
if (_logClientlessCommand)
TC_LOG_INFO("server.adventurebot.navigation",
"CLIENTLESS_COMMAND_REPLY account=%u text='%s'", m_session->GetAccountId(), line);
BuildChatPacket(data, CHAT_MSG_SYSTEM, LANG_UNIVERSAL, nullptr, nullptr, line);
m_session->SendPacket(&data);
}
@@ -411,6 +414,19 @@ bool ChatHandler::SetDataForCommandInTable(std::vector<ChatCommand>& table, char
bool ChatHandler::_ParseCommands(char const* text)
{
// Scope reply mirroring to this command invocation, including syntax errors.
std::string const command(text);
std::string const token = command.substr(0, command.find(' '));
_logClientlessCommand = m_session && !token.empty() &&
hasStringAbbr("clientless", token.c_str());
struct ResetClientlessLog
{
bool& Enabled;
~ResetClientlessLog() { Enabled = false; }
} reset{ _logClientlessCommand };
if (_logClientlessCommand)
TC_LOG_INFO("server.adventurebot.navigation",
"CLIENTLESS_COMMAND account=%u text='%s'", m_session->GetAccountId(), text);
if (ExecuteCommandInTable(getCommandTable(), text, text))
return true;
+1
View File
@@ -147,6 +147,7 @@ class TC_GAME_API ChatHandler
// common global flag
bool sentErrorMessage;
bool _logClientlessCommand = false;
};
class TC_GAME_API CliHandler : public ChatHandler
@@ -49,6 +49,7 @@
#include <G3D/Box.h>
#include <G3D/CoordinateFrame.h>
#include <G3D/Quat.h>
#include <limits>
bool QuaternionData::isUnit() const
{
@@ -330,6 +331,54 @@ public:
}
}
bool GetTransportDockState(TransportDockState& state) const override
{
state = {};
if (!_animationInfo || !_animationInfo->TotalTime)
return false;
state.Progress = _pathProgress;
state.Period = _animationInfo->TotalTime;
if (!_stopFrames.empty())
{
if (int32(GameTime::GetGameTimeMS() - _owner.GetUInt32Value(GAMEOBJECT_LEVEL)) >= 0)
{
state.Stopped = true;
state.RemainingPause = std::numeric_limits<uint32>::max();
}
return true;
}
auto current = _animationInfo->Path.upper_bound(_pathProgress);
if (current == _animationInfo->Path.begin())
return true;
--current;
auto next = std::next(current);
// Consecutive equal animation positions represent an actual dwell,
// unlike a GO state which may already denote a future destination.
auto samePosition = [](TransportAnimationEntry const* a, TransportAnimationEntry const* b)
{
return std::fabs(a->Pos.X - b->Pos.X) < 0.001f &&
std::fabs(a->Pos.Y - b->Pos.Y) < 0.001f && std::fabs(a->Pos.Z - b->Pos.Z) < 0.001f;
};
while (next != _animationInfo->Path.end() && samePosition(current->second, next->second))
++next;
uint32 end = next == std::next(current) ? _pathProgress :
std::prev(next)->first;
// A stationary origin with a rotating deck is not a boarding stop.
auto rotation = _animationInfo->Rotations.upper_bound(_pathProgress);
if (rotation != _animationInfo->Rotations.begin() && rotation != _animationInfo->Rotations.end())
{
auto before = std::prev(rotation)->second;
auto after = rotation->second;
if (std::fabs(before->X - after->X) + std::fabs(before->Y - after->Y) +
std::fabs(before->Z - after->Z) + std::fabs(before->W - after->W) > 0.001f)
return true;
end = std::min(end, rotation->first);
}
state.Stopped = end > _pathProgress;
state.RemainingPause = state.Stopped ? end - _pathProgress : 0;
return true;
}
uint32 GetTransportPeriod() const
{
if (_animationInfo)
@@ -2963,6 +3012,14 @@ void GameObject::BuildValuesUpdate(uint8 updateType, ByteBuffer* data, Player* t
data->append(fieldBuffer);
}
bool GameObject::GetTransportDockState(TransportDockState& state) const
{
state = {};
if (Transport const* transport = ToTransport())
return transport->GetDockState(state);
return m_goTypeImpl && m_goTypeImpl->GetTransportDockState(state);
}
std::vector<uint32> const* GameObject::GetPauseTimes() const
{
if (GameObjectType::Transport const* transport = dynamic_cast<GameObjectType::Transport const*>(m_goTypeImpl.get()))
@@ -33,6 +33,13 @@ class OPvPCapturePoint;
class SpellInfo;
class Transport;
class TransportBase;
struct TransportDockState
{
uint32 Progress = 0;
uint32 Period = 0;
uint32 RemainingPause = 0;
bool Stopped = false;
};
class Unit;
struct TransportAnimation;
enum SpellTargetCheckTypes : uint8;
@@ -55,6 +62,7 @@ public:
virtual void Update([[maybe_unused]] uint32 diff) { }
virtual void OnStateChanged([[maybe_unused]] GOState oldState, [[maybe_unused]] GOState newState) { }
virtual void OnRelocated() { }
virtual bool GetTransportDockState(TransportDockState&) const { return false; }
protected:
GameObject& _owner;
@@ -206,6 +214,7 @@ class TC_GAME_API GameObject : public WorldObject, public GridObject<GameObject>
static void SetGoArtKit(uint8 artkit, GameObject* go, ObjectGuid::LowType lowguid = 0);
std::vector<uint32> const* GetPauseTimes() const;
bool GetTransportDockState(TransportDockState& state) const;
void SetPathProgressForClient(float progress);
void PlayAnimKit(int32 animKit);
@@ -273,6 +273,29 @@ void Transport::Update(uint32 diff)
}
}
bool Transport::GetDockState(TransportDockState& state) const
{
state = {};
if (!_transportInfo || !GetTransportPeriod())
return false;
state.Period = GetTransportPeriod();
state.Progress = _pathProgress % state.Period;
TransportMovementState movement;
size_t index;
if (!_transportInfo->ComputePosition(state.Progress, &movement, &index))
return false;
if (movement == TransportMovementState::WaitingOnPauseWaypoint)
for (TransportPathSegment const& segment : _transportInfo->PathLegs[index].Segments)
if (state.Progress >= segment.SegmentEndArrivalTimestamp &&
state.Progress < segment.SegmentEndArrivalTimestamp + segment.Delay)
{
state.Stopped = true;
state.RemainingPause = segment.SegmentEndArrivalTimestamp + segment.Delay - state.Progress;
break;
}
return true;
}
void Transport::AddPassenger(WorldObject* passenger)
{
if (!IsInWorld())
@@ -90,6 +90,7 @@ class TC_GAME_API Transport : public GameObject, public TransportBase
uint32 GetTransportPeriod() const { return GetUInt32Value(GAMEOBJECT_LEVEL); }
void SetPeriod(uint32 period) { SetUInt32Value(GAMEOBJECT_LEVEL, period); }
uint32 GetTimer() const { return _pathProgress; }
bool GetDockState(TransportDockState& state) const;
void UpdatePosition(float x, float y, float z, float o);
+9 -2
View File
@@ -2383,6 +2383,7 @@ void ObjectMgr::RemoveCreatureFromGrid(ObjectGuid::LowType guid, CreatureData co
void ObjectMgr::LoadGameObjects()
{
uint32 oldMSTime = getMSTime();
_transportGameObjectSpawns.clear();
// 0 1 2 3 4 5 6
QueryResult result = WorldDatabase.Query("SELECT gameobject.guid, id, map, position_x, position_y, position_z, orientation, "
@@ -2595,7 +2596,13 @@ void ObjectMgr::LoadGameObjects()
}
if (gameEvent == 0) // if not this is to be managed by GameEvent System or Pool system
{
AddGameobjectToGrid(guid, &data);
// These objects become active only after their spawn grid is loaded.
// Index them so a distant station cannot leave half a service absent.
if (gInfo->type == GAMEOBJECT_TYPE_TRANSPORT)
_transportGameObjectSpawns[data.mapId].push_back(guid);
}
}
while (result->NextRow());
@@ -3797,14 +3804,14 @@ void ObjectMgr::LoadPlayerInfo()
Field* fields = result->Fetch();
uint32 current_class = fields[0].GetUInt8();
uint32 current_class = fields[0].GetUInt16();
if (current_class >= MAX_CLASSES)
{
TC_LOG_ERROR("sql.sql", "Wrong class %u in `player_levelstats` table, ignoring.", current_class);
continue;
}
uint32 current_level = fields[1].GetUInt8();
uint32 current_level = fields[1].GetUInt16();
if (current_level > sWorld->getIntConfig(CONFIG_MAX_PLAYER_LEVEL))
{
if (current_level > STRONG_MAX_LEVEL) // hardcoded level maximum
+6
View File
@@ -1392,6 +1392,11 @@ class TC_GAME_API ObjectMgr
return &itr->second;
}
GameObjectDataContainer const& GetAllGameObjectData() const { return _gameObjectDataStore; }
std::vector<ObjectGuid::LowType> const* GetTransportGameObjectSpawns(uint32 mapId) const
{
auto itr = _transportGameObjectSpawns.find(mapId);
return itr != _transportGameObjectSpawns.end() ? &itr->second : nullptr;
}
GameObjectData const* GetGameObjectData(ObjectGuid::LowType spawnId) const
{
GameObjectDataContainer::const_iterator itr = _gameObjectDataStore.find(spawnId);
@@ -1752,6 +1757,7 @@ class TC_GAME_API ObjectMgr
LinkedRespawnContainer _linkedRespawnStore;
CreatureLocaleContainer _creatureLocaleStore;
GameObjectDataContainer _gameObjectDataStore;
std::unordered_map<uint32, std::vector<ObjectGuid::LowType>> _transportGameObjectSpawns;
GameObjectLocaleContainer _gameObjectLocaleStore;
GameObjectTemplateContainer _gameObjectTemplateStore;
GameObjectTemplateAddonContainer _gameObjectTemplateAddonStore;
+57 -4
View File
@@ -754,6 +754,56 @@ void WorldSession::HandlePlayerLoginOpcode(WorldPackets::Character::PlayerLogin&
SendConnectToInstance(WorldPackets::Auth::ConnectToSerial::WorldAttempt1);
}
bool WorldSession::InitializeClientlessPlayer(ObjectGuid playerGuid)
{
if (!IsClientless())
{
TC_LOG_ERROR("server.worldserver",
"WorldSession::InitializeClientlessPlayer rejected a non-clientless session for account %u.",
GetAccountId());
return false;
}
if (!playerGuid || !_clientlessPendingPlayerGuid.IsEmpty() || PlayerLoading() || GetPlayer())
{
TC_LOG_ERROR("server.worldserver",
"WorldSession::InitializeClientlessPlayer rejected invalid initialization state for account %u, character GUID %u.",
GetAccountId(), uint32(playerGuid.GetCounter()));
return false;
}
_clientlessPendingPlayerGuid = playerGuid;
InitializeSession();
return true;
}
bool WorldSession::BeginClientlessPlayerLogin(ObjectGuid playerGuid)
{
if (!IsClientless())
{
TC_LOG_ERROR("server.worldserver",
"WorldSession::BeginClientlessPlayerLogin rejected a non-clientless session for account %u.",
GetAccountId());
return false;
}
if (!playerGuid || PlayerLoading() || GetPlayer())
{
TC_LOG_ERROR("server.worldserver",
"WorldSession::BeginClientlessPlayerLogin rejected invalid login state for account %u, character GUID %u.",
GetAccountId(), uint32(playerGuid.GetCounter()));
return false;
}
// Clientless login deliberately does not use _legitCharacters.
// That set is populated by CMSG_ENUM_CHARACTERS and is therefore
// protocol/client state, not account ownership authority. Player::LoadFromDB
// performs the authoritative account-id check against the character row.
m_playerLoading = playerGuid;
HandleContinuePlayerLogin();
return true;
}
void WorldSession::HandleContinuePlayerLogin()
{
if (!PlayerLoading() || GetPlayer())
@@ -769,7 +819,7 @@ void WorldSession::HandleContinuePlayerLogin()
return;
}
if (!_legacyConnectionModeEnabled)
if (IsNetworkSession() && !_legacyConnectionModeEnabled)
SendPacket(WorldPackets::Auth::ResumeComms(CONNECTION_TYPE_INSTANCE).Write());
AddQueryHolderCallback(CharacterDatabase.DelayQueryHolder(holder)).AfterComplete([this](SQLQueryHolderBase const& holder)
@@ -938,9 +988,12 @@ void WorldSession::HandlePlayerLogin(LoginQueryHolder const& holder)
stmt->setUInt32(0, pCurrChar->GetGUID().GetCounter());
CharacterDatabase.Execute(stmt);
LoginDatabasePreparedStatement* loginStmt = LoginDatabase.GetPreparedStatement(LOGIN_UPD_ACCOUNT_ONLINE);
loginStmt->setUInt32(0, GetAccountId());
LoginDatabase.Execute(loginStmt);
if (IsNetworkSession())
{
LoginDatabasePreparedStatement* loginStmt = LoginDatabase.GetPreparedStatement(LOGIN_UPD_ACCOUNT_ONLINE);
loginStmt->setUInt32(0, GetAccountId());
LoginDatabase.Execute(loginStmt);
}
pCurrChar->SetInGameTime(GameTime::GetGameTimeMS());
@@ -244,6 +244,24 @@ void WorldSession::HandleMoveTeleportAck(WorldPackets::Movement::MoveTeleportAck
return;
CompleteNearTeleport(plMover);
}
void WorldSession::CompleteClientlessTeleport()
{
if (!IsClientless() || !GetPlayer())
return;
bool const pending = GetPlayer()->IsBeingTeleported();
if (GetPlayer()->IsBeingTeleportedFar())
HandleMoveWorldportAck();
else if (GetPlayer()->IsBeingTeleportedNear())
CompleteNearTeleport(GetPlayer());
if (pending && GetPlayer() && GetPlayer()->IsInWorld() && !GetPlayer()->IsBeingTeleported())
++_clientlessTeleportSequence;
}
void WorldSession::CompleteNearTeleport(Player* plMover)
{
plMover->SetSemaphoreTeleportNear(false);
uint32 old_zone = plMover->GetZoneId();
+40
View File
@@ -41,6 +41,7 @@ GridMap::GridMap()
m_V9 = nullptr;
m_V8 = nullptr;
_minHeightPlanes = nullptr;
_maxHeightPlanes = nullptr;
// Liquid data
_liquidGlobalEntry = 0;
_liquidGlobalFlags = map_liquidHeaderTypeFlags::NoWater;
@@ -123,6 +124,7 @@ void GridMap::unloadData()
delete[] m_V9;
delete[] m_V8;
delete[] _minHeightPlanes;
delete[] _maxHeightPlanes;
delete[] _liquidEntry;
delete[] _liquidFlags;
delete[] _liquidMap;
@@ -131,6 +133,7 @@ void GridMap::unloadData()
m_V9 = nullptr;
m_V8 = nullptr;
_minHeightPlanes = nullptr;
_maxHeightPlanes = nullptr;
_liquidEntry = nullptr;
_liquidFlags = nullptr;
_liquidMap = nullptr;
@@ -233,6 +236,13 @@ bool GridMap::loadHeightData(FILE* in, uint32 offset, uint32 /*size*/)
{ -533.33331f, -533.33331f }
};
_maxHeightPlanes = new G3D::Plane[8];
for (uint32 quarterIndex = 0; quarterIndex < 8; ++quarterIndex)
_maxHeightPlanes[quarterIndex] = G3D::Plane(
G3D::Vector3(boundGridCoords[indices[quarterIndex][0]][0], boundGridCoords[indices[quarterIndex][0]][1], maxHeights[indices[quarterIndex][0]]),
G3D::Vector3(boundGridCoords[indices[quarterIndex][1]][0], boundGridCoords[indices[quarterIndex][1]][1], maxHeights[indices[quarterIndex][1]]),
G3D::Vector3(boundGridCoords[indices[quarterIndex][2]][0], boundGridCoords[indices[quarterIndex][2]][1], maxHeights[indices[quarterIndex][2]])
);
_minHeightPlanes = new G3D::Plane[8];
for (uint32 quarterIndex = 0; quarterIndex < 8; ++quarterIndex)
_minHeightPlanes[quarterIndex] = G3D::Plane(
@@ -565,6 +575,36 @@ float GridMap::getMinHeight(float x, float y) const
return ray.intersection(_minHeightPlanes[quarterIndex]).z;
}
float GridMap::getMaxHeight(float x, float y) const
{
if (!_maxHeightPlanes)
return INVALID_HEIGHT;
GridCoord gridCoord = Trinity::ComputeGridCoordSimple(x, y);
int32 doubleGridX = int32(std::floor(-(x - MAP_HALFSIZE) / CENTER_GRID_OFFSET));
int32 doubleGridY = int32(std::floor(-(y - MAP_HALFSIZE) / CENTER_GRID_OFFSET));
float gx = x - (int32(gridCoord.x_coord) - CENTER_GRID_ID + 1) * SIZE_OF_GRIDS;
float gy = y - (int32(gridCoord.y_coord) - CENTER_GRID_ID + 1) * SIZE_OF_GRIDS;
uint32 quarterIndex = 0;
if (doubleGridY & 1)
{
if (doubleGridX & 1)
quarterIndex = 4 + (gx <= gy);
else
quarterIndex = 2 + ((-SIZE_OF_GRIDS - gx) > gy);
}
else if (doubleGridX & 1)
quarterIndex = 6 + ((-SIZE_OF_GRIDS - gx) <= gy);
else
quarterIndex = gx > gy;
G3D::Ray ray = G3D::Ray::fromOriginAndDirection(G3D::Vector3(gx, gy, 0.0f), G3D::Vector3::unitZ());
return ray.intersection(_maxHeightPlanes[quarterIndex]).z;
}
bool GridMap::isHole(int row, int col) const
{
if (!_holes)
+2
View File
@@ -42,6 +42,7 @@ class TC_GAME_API GridMap
uint8* m_uint8_V8;
};
G3D::Plane* _minHeightPlanes;
G3D::Plane* _maxHeightPlanes;
// Height level data
float _gridHeight;
float _gridIntHeightMultiplier;
@@ -95,6 +96,7 @@ public:
uint16 getArea(float x, float y) const;
inline float getHeight(float x, float y) const {return (this->*_gridGetHeight)(x, y);}
float getMinHeight(float x, float y) const;
float getMaxHeight(float x, float y) const;
float getLiquidLevel(float x, float y) const;
ZLiquidStatus GetLiquidStatus(float x, float y, float z, Optional<map_liquidHeaderTypeFlags> ReqLiquidType, LiquidData* data = 0, float collisionHeight = 2.03128f) const; // DEFAULT_COLLISION_HEIGHT in Object.h
};
+21
View File
@@ -116,6 +116,27 @@ void Map::LoadAllCells()
LoadGrid((cellX + 0.5f - CENTER_GRID_CELL_ID) * SIZE_OF_GRID_CELL, (cellY + 0.5f - CENTER_GRID_CELL_ID) * SIZE_OF_GRID_CELL);
}
void Map::LoadTransportGameObjectGrids()
{
auto spawns = sObjectMgr->GetTransportGameObjectSpawns(GetId());
if (!spawns)
return;
uint32 grids = 0;
for (ObjectGuid::LowType spawnId : *spawns)
{
GameObjectData const* data = sObjectMgr->GetGameObjectData(spawnId);
if (!data || data->mapId != GetId() || !(data->spawnMask & (1u << GetSpawnMode())))
continue;
// Use the normal loader: respawns, pools, phases and spawn groups retain
// their existing rules. Loading an already loaded grid is idempotent.
if (EnsureGridLoaded(Cell(data->spawnPoint.GetPositionX(), data->spawnPoint.GetPositionY())))
++grids;
}
TC_LOG_INFO("server.loading", "Initialized transport GameObject grids: map=%u instance=%u grids=%u candidates=%u",
GetId(), GetInstanceId(), grids, uint32(spawns->size()));
}
void Map::InitStateMachine()
{
si_GridStates[GRID_STATE_INVALID] = new InvalidState();
+1
View File
@@ -218,6 +218,7 @@ class TC_GAME_API Map : public GridRefManager<NGridType>
void SetUnloadLock(GridCoord const& p, bool on) { getNGrid(p.x_coord, p.y_coord)->setUnloadExplicitLock(on); }
void LoadGrid(float x, float y);
void LoadAllCells();
void LoadTransportGameObjectGrids();
bool UnloadGrid(NGridType& ngrid, bool pForce);
void GridMarkNoUnload(uint32 x, uint32 y);
void GridUnmarkNoUnload(uint32 x, uint32 y);
+5
View File
@@ -74,6 +74,8 @@ Map* MapManager::CreateWorldMap(uint32 mapId, uint32 instanceId)
map->LoadRespawnTimes();
map->LoadCorpseData();
map->LoadTransportGameObjectGrids();
if (sWorld->getBoolConfig(CONFIG_BASEMAP_LOAD_GRIDS))
map->LoadAllCells();
@@ -104,6 +106,8 @@ InstanceMap* MapManager::CreateInstance(uint32 mapId, uint32 instanceId, Instanc
bool load_data = save != nullptr;
map->CreateInstanceData(load_data);
map->LoadTransportGameObjectGrids();
if (sWorld->getBoolConfig(CONFIG_INSTANCEMAP_LOAD_GRIDS))
map->LoadAllCells();
@@ -118,6 +122,7 @@ BattlegroundMap* MapManager::CreateBattleground(uint32 mapId, uint32 instanceId,
ASSERT(map->IsBattlegroundOrArena());
map->SetBG(bg);
bg->SetBgMap(map);
map->LoadTransportGameObjectGrids();
return map;
}
+8
View File
@@ -657,6 +657,14 @@ float TerrainInfo::GetMinHeight(PhaseShift const& phaseShift, uint32 mapId, floa
return -500.0f;
}
float TerrainInfo::GetMaxHeight(PhaseShift const& phaseShift, uint32 mapId, float x, float y)
{
if (GridMap const* grid = GetGrid(PhasingHandler::GetTerrainMapId(phaseShift, mapId, this, x, y), x, y))
return grid->getMaxHeight(x, y);
return VMAP_INVALID_HEIGHT_VALUE;
}
float TerrainInfo::GetGridHeight(PhaseShift const& phaseShift, uint32 mapId, float x, float y)
{
if (GridMap* gmap = GetGrid(PhasingHandler::GetTerrainMapId(phaseShift, mapId, this, x, y), x, y))
+1
View File
@@ -92,6 +92,7 @@ public:
void GetZoneAndAreaId(PhaseShift const& phaseShift, uint32 mapId, uint32& zoneid, uint32& areaid, Position const& pos) { GetZoneAndAreaId(phaseShift, mapId, zoneid, areaid, pos.GetPositionX(), pos.GetPositionY(), pos.GetPositionZ()); }
float GetMinHeight(PhaseShift const& phaseShift, uint32 mapId, float x, float y);
float GetMaxHeight(PhaseShift const& phaseShift, uint32 mapId, float x, float y);
float GetGridHeight(PhaseShift const& phaseShift, uint32 mapId, float x, float y);
float GetStaticHeight(PhaseShift const& phaseShift, uint32 mapId, float x, float y, float z, bool checkVMap = true, float maxSearchDist = DEFAULT_HEIGHT_SEARCH);
float GetStaticHeight(PhaseShift const& phaseShift, uint32 mapId, Position const& pos, bool checkVMap = true, float maxSearchDist = DEFAULT_HEIGHT_SEARCH) { return GetStaticHeight(phaseShift, mapId, pos.GetPositionX(), pos.GetPositionY(), pos.GetPositionZ(), checkVMap, maxSearchDist); }
+115
View File
@@ -30,6 +30,121 @@
#include "Metric.h"
#include "PhasingHandler.h"
// VCORE_POSITION_MMAP_BEGIN
VcorePositionPathResult PathGenerator::AnalyzePositions(uint32 meshMapId, uint32 instanceMapId,
uint32 instanceId, Position const& start, Position const& destination, uint16 includeFlags)
{
VcorePositionPathResult result;
G3D::Vector3 const startPos(start.GetPositionX(), start.GetPositionY(), start.GetPositionZ());
G3D::Vector3 const endPos(destination.GetPositionX(), destination.GetPositionY(), destination.GetPositionZ());
result.DirectDistance = (endPos - startPos).length();
result.FinalDistance = result.DirectDistance;
if (!Trinity::IsValidMapCoord(startPos.x, startPos.y, startPos.z) ||
!Trinity::IsValidMapCoord(endPos.x, endPos.y, endPos.z) || !includeFlags)
{
result.Result = VcorePositionPathResult::Status::InvalidCoordinates;
return result;
}
if (!DisableMgr::IsPathfindingEnabled(meshMapId))
return result; // NavMeshUnavailable
MMAP::MMapManager* manager = MMAP::MMapFactory::createOrGetMMapManager();
dtNavMeshQuery const* query = manager->GetNavMeshQuery(meshMapId, instanceMapId, instanceId);
dtNavMesh const* mesh = query ? query->getAttachedNavMesh() : nullptr;
if (!query || !mesh)
return result;
// Detour uses Y,Z,X; world coordinates use X,Y,Z.
float const from[3] = {startPos.y, startPos.z, startPos.x};
float const to[3] = {endPos.y, endPos.z, endPos.x};
for (float const* point : {from, to})
{
int tileX = -1, tileY = -1;
mesh->calcTileLoc(point, &tileX, &tileY);
if (tileX < 0 || tileY < 0 || !mesh->getTileAt(tileX, tileY, 0))
{
result.Result = VcorePositionPathResult::Status::TileUnavailable;
return result;
}
}
dtQueryFilter filter;
filter.setIncludeFlags(includeFlags);
filter.setExcludeFlags(0);
float const extents[3] = {3.0f, 5.0f, 3.0f};
float nearestStart[3] = {}, nearestEnd[3] = {};
dtPolyRef startPoly = 0, endPoly = 0;
if (dtStatusFailed(query->findNearestPoly(from, extents, &filter, &startPoly, nearestStart)) ||
dtStatusFailed(query->findNearestPoly(to, extents, &filter, &endPoly, nearestEnd)) ||
!startPoly || !endPoly)
{
result.Result = VcorePositionPathResult::Status::NoPolygon;
return result;
}
// A distant projection is not proof that the requested world point is walkable.
if (dtVdist(from, nearestStart) > 3.0f || dtVdist(to, nearestEnd) > 3.0f)
{
result.Result = VcorePositionPathResult::Status::NoPolygon;
return result;
}
dtPolyRef polys[MAX_PATH_LENGTH] = {};
int polyCount = 0;
dtStatus const pathStatus = query->findPath(startPoly, endPoly, nearestStart, nearestEnd,
&filter, polys, &polyCount, MAX_PATH_LENGTH);
if (dtStatusFailed(pathStatus) || polyCount <= 0)
{
result.Result = VcorePositionPathResult::Status::NoPath;
return result;
}
bool const reachedEndPoly = polys[polyCount - 1] == endPoly;
float reachableEnd[3] = {nearestEnd[0], nearestEnd[1], nearestEnd[2]};
if (!reachedEndPoly &&
dtStatusFailed(query->closestPointOnPoly(polys[polyCount - 1], nearestEnd, reachableEnd, nullptr)))
{
result.Result = VcorePositionPathResult::Status::NoPath;
return result;
}
// Use an explicit bounded straight-path representation, not a forced shortcut.
constexpr int MaxPoints = 256;
float straight[MaxPoints * 3] = {};
int pointCount = 0;
dtStatus const straightStatus = query->findStraightPath(nearestStart, reachableEnd, polys,
polyCount, straight, nullptr, nullptr, &pointCount, MaxPoints);
if (dtStatusFailed(straightStatus) || pointCount <= 0)
{
result.Result = VcorePositionPathResult::Status::NoPath;
return result;
}
result.Points.reserve(pointCount);
for (int i = 0; i < pointCount; ++i)
{
G3D::Vector3 const p(straight[i * 3 + 2], straight[i * 3], straight[i * 3 + 1]);
if (!result.Points.empty())
result.PathLength += (p - result.Points.back()).length();
result.Points.push_back(p);
}
result.FinalDistance = (endPos - result.Points.back()).length();
bool const truncated = (pathStatus & DT_BUFFER_TOO_SMALL) ||
(straightStatus & DT_BUFFER_TOO_SMALL) || pointCount >= MaxPoints ||
polyCount >= MAX_PATH_LENGTH;
if (truncated)
result.Result = VcorePositionPathResult::Status::Truncated;
else if (reachedEndPoly && result.FinalDistance <= 1.5f)
result.Result = VcorePositionPathResult::Status::Complete;
else
result.Result = VcorePositionPathResult::Status::Partial;
return result;
}
// VCORE_POSITION_MMAP_END
////////////////// PathGenerator //////////////////
PathGenerator::PathGenerator(WorldObject const* owner) :
_polyLength(0), _type(PATHFIND_BLANK), _useStraightPath(false),
+36 -1
View File
@@ -23,6 +23,7 @@
#include "MMapDefines.h"
#include "MoveSplineInitArgs.h"
#include <G3D/Vector3.h>
#include <vector>
class Unit;
class WorldObject;
@@ -54,6 +55,34 @@ enum PathType
PATHFIND_FARFROMPOLY = PATHFIND_FARFROMPOLY_START | PATHFIND_FARFROMPOLY_END, // start or end positions are far from the mmap poligon
};
// VCORE_POSITION_MMAP_BEGIN
// Ground-only navmesh probe without a Player or WorldObject.
// meshMapId may differ from instanceMapId for phased/terrain-swapped maps.
// The caller must run this on the map/world thread that owns the navmesh query.
struct VcorePositionPathResult
{
enum class Status : uint8
{
InvalidCoordinates,
NavMeshUnavailable,
TileUnavailable,
NoPolygon,
NoPath,
Partial,
Complete,
Truncated
};
Status Result = Status::NavMeshUnavailable;
float DirectDistance = 0.0f;
float PathLength = 0.0f;
float FinalDistance = 0.0f;
std::vector<G3D::Vector3> Points;
bool IsComplete() const { return Result == Status::Complete; }
};
// VCORE_POSITION_MMAP_END
class TC_GAME_API PathGenerator
{
public:
@@ -65,7 +94,13 @@ class TC_GAME_API PathGenerator
PathGenerator& operator=(PathGenerator const& right) = delete;
PathGenerator& operator=(PathGenerator&& right) = delete;
// Calculate the path from owner to given destination
// Vcore: query loaded MMAP data between arbitrary coordinates; no character required.
// The result describes ground navmesh reachability, not dynamic world collision.
static VcorePositionPathResult AnalyzePositions(uint32 meshMapId, uint32 instanceMapId,
uint32 instanceId, Position const& start, Position const& destination,
uint16 includeFlags = NAV_GROUND);
// Calculate the path from owner to given destination
// return: true if new path was calculated, false otherwise (no change needed)
bool CalculatePath(float destX, float destY, float destZ, bool forceDest = false);
bool CalculatePath(Position const& destination, bool forceDest = false);
+54 -20
View File
@@ -108,12 +108,14 @@ bool WorldSessionFilter::Process(WorldPacket* packet)
}
/// WorldSession constructor
WorldSession::WorldSession(uint32 id, std::string&& name, std::shared_ptr<WorldSocket> sock, AccountTypes sec, uint8 expansion, time_t mute_time, LocaleConstant locale, uint32 recruiter, bool isARecruiter) :
WorldSession::WorldSession(uint32 id, std::string&& name, std::shared_ptr<WorldSocket> sock, AccountTypes sec, uint8 expansion, time_t mute_time, LocaleConstant locale, uint32 recruiter, bool isARecruiter, WorldSession::WorldSessionMode sessionMode) :
m_muteTime(mute_time),
m_timeOutTime(0),
AntiDOS(this),
m_GUIDLow(0),
_player(nullptr),
_sessionMode(sessionMode),
_clientlessPendingPlayerGuid(),
_security(sec),
_accountId(id),
_accountName(std::move(name)),
@@ -147,12 +149,18 @@ WorldSession::WorldSession(uint32 id, std::string&& name, std::shared_ptr<WorldS
_timeSyncNextCounter = 0;
_timeSyncTimer = 0;
if (sock)
if (IsNetworkSession() && sock)
{
m_Address = sock->GetRemoteIpAddress().to_string();
ResetTimeOutTime(false);
LoginDatabase.PExecute("UPDATE account SET online = 1 WHERE id = %u;", GetAccountId()); // One-time query
}
else if (IsClientless())
{
// Clientless sessions deliberately have no remote endpoint.
// Keep a stable diagnostic value for logs that include GetRemoteAddress().
m_Address = "<clientless>";
}
m_Socket[CONNECTION_TYPE_REALM] = sock;
_instanceConnectKey.Raw = UI64LIT(0);
@@ -185,7 +193,8 @@ WorldSession::~WorldSession()
while (_recvQueue.next(packet))
delete packet;
LoginDatabase.PExecute("UPDATE account SET online = 0 WHERE id = %u;", GetAccountId()); // One-time query
if (IsNetworkSession())
LoginDatabase.PExecute("UPDATE account SET online = 0 WHERE id = %u;", GetAccountId()); // One-time query
}
bool WorldSession::PlayerDisconnected() const
@@ -223,6 +232,12 @@ ObjectGuid::LowType WorldSession::GetGUIDLow() const
/// Send a packet to the client
void WorldSession::SendPacket(WorldPacket const* packet, bool forced /*= false*/)
{
// A clientless session intentionally has no packet transport. Outbound
// packets are a normal side effect of Player/Core code and are discarded
// without producing "non existent socket" errors.
if (IsClientless())
return;
if (packet->GetOpcode() == NULL_OPCODE)
{
TC_LOG_ERROR("network.opcode", "Prevented sending of NULL_OPCODE to %s", GetPlayerInfo().c_str());
@@ -340,13 +355,16 @@ void WorldSession::LogUnprocessedTail(WorldPacket const* packet)
/// Update the WorldSession (triggered by World update)
bool WorldSession::Update(uint32 diff, PacketFilter& updater)
{
/// Update Timeout timer.
UpdateTimeOutTime(diff);
if (IsNetworkSession())
{
/// Update Timeout timer.
UpdateTimeOutTime(diff);
///- Before we process anything:
/// If necessary, kick the player from the character select screen
if (IsConnectionIdle() && !HasPermission(rbac::RBAC_PERM_IGNORE_IDLE_CONNECTION))
m_Socket[CONNECTION_TYPE_REALM]->CloseSocket();
///- Before we process anything:
/// If necessary, kick the player from the character select screen
if (IsConnectionIdle() && !HasPermission(rbac::RBAC_PERM_IGNORE_IDLE_CONNECTION) && m_Socket[CONNECTION_TYPE_REALM])
m_Socket[CONNECTION_TYPE_REALM]->CloseSocket();
}
///- Retrieve packets from the receive queue and call the appropriate handlers
/// not process packets if socket already closed
@@ -478,7 +496,7 @@ bool WorldSession::Update(uint32 diff, PacketFilter& updater)
if (m_Socket[0] && m_Socket[0]->IsOpen() && _warden)
_warden->Update();
if (!updater.ProcessUnsafe()) // <=> updater is of type MapSessionFilter
if (IsNetworkSession() && !updater.ProcessUnsafe()) // <=> updater is of type MapSessionFilter
{
// Send time sync packet every 5s.
if (_timeSyncTimer > 0)
@@ -523,8 +541,8 @@ bool WorldSession::Update(uint32 diff, PacketFilter& updater)
}
}
if (!m_Socket[CONNECTION_TYPE_REALM])
return false; //Will remove this session from the world session map
if (IsNetworkSession() && !m_Socket[CONNECTION_TYPE_REALM])
return false; // Will remove this network session from the world session map
}
return true;
@@ -1244,20 +1262,36 @@ void WorldSession::InitializeSession()
void WorldSession::InitializeSessionCallback(CharacterDatabaseQueryHolder const& realmHolder)
{
// Shared session/account state used by both Network and Clientless players.
LoadAccountData(realmHolder.GetPreparedResult(AccountInfoQueryHolderPerRealm::GLOBAL_ACCOUNT_DATA), GLOBAL_CACHE_MASK);
LoadTutorialsData(realmHolder.GetPreparedResult(AccountInfoQueryHolderPerRealm::TUTORIALS));
if (!m_inQueue)
SendAuthResponse(AUTH_OK, false);
else
SendAuthWaitQue(0);
if (IsNetworkSession())
{
if (!m_inQueue)
SendAuthResponse(AUTH_OK, false);
else
SendAuthWaitQue(0);
ResetTimeOutTime(false);
SendAddonsInfo();
SendClientCacheVersion(sWorld->getIntConfig(CONFIG_CLIENTCACHE_VERSION));
SendTutorialsData();
}
SetInQueue(false);
ResetTimeOutTime(false);
SendAddonsInfo();
SendClientCacheVersion(sWorld->getIntConfig(CONFIG_CLIENTCACHE_VERSION));
SendTutorialsData();
if (IsClientless() && !_clientlessPendingPlayerGuid.IsEmpty())
{
ObjectGuid playerGuid = _clientlessPendingPlayerGuid;
_clientlessPendingPlayerGuid.Clear();
if (!BeginClientlessPlayerLogin(playerGuid))
TC_LOG_ERROR("server.worldserver",
"WorldSession::InitializeSessionCallback failed to continue clientless login for account %u, character GUID %u.",
GetAccountId(), uint32(playerGuid.GetCounter()));
}
}
rbac::RBACData* WorldSession::GetRBACData()
+32 -1
View File
@@ -475,7 +475,14 @@ struct PacketCounter
class TC_GAME_API WorldSession
{
public:
WorldSession(uint32 id, std::string&& name, std::shared_ptr<WorldSocket> sock, AccountTypes sec, uint8 expansion, time_t mute_time, LocaleConstant locale, uint32 recruiter, bool isARecruiter);
enum class WorldSessionMode : uint8
{
Network,
Clientless
};
WorldSession(uint32 id, std::string&& name, std::shared_ptr<WorldSocket> sock, AccountTypes sec, uint8 expansion, time_t mute_time, LocaleConstant locale, uint32 recruiter, bool isARecruiter,
WorldSessionMode sessionMode = WorldSessionMode::Network);
~WorldSession();
bool PlayerLoading() const { return !m_playerLoading.IsEmpty(); }
@@ -513,6 +520,10 @@ class TC_GAME_API WorldSession
AccountTypes GetSecurity() const { return _security; }
uint32 GetAccountId() const { return _accountId; }
WorldSessionMode GetSessionMode() const { return _sessionMode; }
bool IsNetworkSession() const { return _sessionMode == WorldSessionMode::Network; }
bool IsClientless() const { return _sessionMode == WorldSessionMode::Clientless; }
bool HasNetworkTransport() const { return m_Socket[CONNECTION_TYPE_REALM] != nullptr; }
Player* GetPlayer() const { return _player; }
std::string const& GetPlayerName() const;
std::string GetPlayerInfo() const;
@@ -676,6 +687,20 @@ class TC_GAME_API WorldSession
void HandleCharCreateOpcode(WorldPacket& recvPacket);
void HandlePlayerLoginOpcode(WorldPackets::Character::PlayerLogin& packet);
// Initializes shared account/session state and then starts the normal
// Player DB-load/login pipeline for a registered Clientless session.
bool InitializeClientlessPlayer(ObjectGuid playerGuid);
bool HasPendingClientlessPlayerLogin() const
{
return IsClientless() && (!_clientlessPendingPlayerGuid.IsEmpty() || PlayerLoading());
}
// Starts the normal Player DB-load/login pipeline without relying on
// client character-enumeration state or a network login opcode.
// The WorldSession must already be a registered Clientless session
// with the correct account context.
bool BeginClientlessPlayerLogin(ObjectGuid playerGuid);
void SendConnectToInstance(WorldPackets::Auth::ConnectToSerial serial);
void HandleContinuePlayerLogin();
void AbortLogin(WorldPackets::Character::LoginFailureReason reason);
@@ -718,6 +743,8 @@ class TC_GAME_API WorldSession
void HandleForceSpeedChangeAck(WorldPacket& recvData);
void HandleMoveKnockBackAck(WorldPacket& recvPacket);
void HandleMoveTeleportAck(WorldPackets::Movement::MoveTeleportAck& packet);
void CompleteClientlessTeleport();
uint64 GetClientlessTeleportSequence() const { return _clientlessTeleportSequence; }
void HandleMoveWaterWalkAck(WorldPacket& recvPacket);
void HandleFeatherFallAck(WorldPacket& recvData);
void HandleMoveHoverAck(WorldPacket& recvData);
@@ -1293,6 +1320,7 @@ class TC_GAME_API WorldSession
void SendLoadCUFProfiles();
private:
void CompleteNearTeleport(Player* playerMover);
void ProcessQueryCallbacks();
QueryCallbackProcessor _queryProcessor;
@@ -1353,6 +1381,9 @@ class TC_GAME_API WorldSession
ObjectGuid::LowType m_GUIDLow; // set logined or recently logout player (while m_playerRecentlyLogout set)
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!
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,125 @@
/*
* Vcore clientless semantic action executor.
* C8.4: Lua action -> perception query -> entity selection -> travel.
*/
#ifndef VCORE_CLIENTLESS_ACTION_EXECUTOR_MGR_H
#define VCORE_CLIENTLESS_ACTION_EXECUTOR_MGR_H
#include "Common.h"
#include "ClientlessTravelGraphMgr.h"
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
class Player;
struct ClientlessAction;
enum class ClientlessActionExecutionState : uint8
{
Idle,
SelectingTarget,
Moving,
Completed,
Failed
};
struct TC_GAME_API ClientlessActionExecutionStatus
{
ClientlessActionExecutionState State = ClientlessActionExecutionState::Idle;
std::string ActionName;
std::string TargetName;
uint32 TargetGuid = 0;
uint32 TargetEntry = 0;
std::string Detail;
};
class TC_GAME_API ClientlessActionExecutorMgr
{
public:
static ClientlessActionExecutorMgr* instance();
void Update(uint32 diff);
void Clear(uint32 characterGuid);
ClientlessActionExecutionStatus const* GetStatus(uint32 characterGuid) const;
static char const* StateName(ClientlessActionExecutionState state);
private:
struct Record
{
ClientlessActionExecutionStatus Status;
bool Active = false;
std::vector<ClientlessTravelRouteStep> Route;
std::size_t RouteStep = 0;
bool DeferredStartActive = false; // VCORE_C819_DEFERRED_START_NODES_V2
uint32 DeferredStartAttempts = 0;
std::vector<ClientlessTravelEdgePoint> EdgePath;
// C8.10b: active server-side taxi edge.
bool TransportActive = false;
bool FlightActive = false;
bool FlightApproachActive = false;
bool PortalUseActive = false;
uint64 PortalUseTeleportSequence = 0;
uint32 PortalUseElapsedMs = 0;
uint32 PortalUseSpawnId = 0;
bool TaxiActive = false;
uint32 TaxiPathId = 0;
uint32 TaxiSourceNodeId = 0;
uint32 TaxiDestinationNodeId = 0;
bool FinalLocalLegPending = false;
bool FinalLocalLegActive = false;
uint32 FinalMapId = 0;
float FinalX = 0.0f;
float FinalY = 0.0f;
float FinalZ = 0.0f;
float FinalO = 0.0f;
uint32 FinalDatabaseId = 0;
std::string FinalLocationId;
// C8.11b travel recovery. Failed edges are blacklisted only for the
// current action; nothing is disabled persistently in the database.
std::unordered_set<uint32> FailedEdgeDatabaseIds;
uint32 RecoveryCount = 0;
uint32 RecoveryWanderAttempts = 0;
bool RecoveryWanderActive = false;
float PlannedStartRadius = 500.0f;
float PlannedAnchorRadius = 1000.0f;
std::string PlannedDestinationNode;
// C8.13b adaptive graph bridging / best-effort travel.
std::unordered_set<std::string> FailedBridgeKeys;
bool RouteComplete = true;
bool HardDestination = false;
float LocalBridgeRadius = 300.0f;
float LocalBridgePathMax = 500.0f;
std::string AdaptiveReachedNode;
};
bool StartAction(Player* player, ClientlessAction const& action, Record& record);
bool StartApproach(Player* player, ClientlessAction const& action, Record& record);
bool StartMoveToPoint(Player* player, ClientlessAction const& action, Record& record);
bool StartTravelToNode(Player* player, ClientlessAction const& action, Record& record);
bool StartTravelToLocation(Player* player, ClientlessAction const& action, Record& record);
bool StartFinalLocalLeg(Player* player, uint32 characterGuid, Record& record);
bool RecoverFinalLocalLeg(Player* player, uint32 characterGuid, Record& record, std::string const& reason);
bool StartRouteStep(Player* player, uint32 characterGuid, Record& record);
bool RecoverFlightLeg(Player* player, uint32 characterGuid, Record& record, std::string const& reason);
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);
void UpdateTravelToNode(Player* player, uint32 characterGuid, Record& record);
void UpdateTravelToLocation(Player* player, uint32 characterGuid, Record& record);
void UpdateActive(Player* player, uint32 characterGuid, Record& record);
std::unordered_map<uint32, Record> _records;
};
#define sClientlessActionExecutorMgr ClientlessActionExecutorMgr::instance()
#endif
@@ -0,0 +1,525 @@
#include "ClientlessFlightMgr.h"
#include "ClientlessLuaEngine.h"
#include "ClientlessTravelMgr.h"
#include "ClientlessActionExecutorMgr.h"
#include "ClientlessPlayerMgr.h"
#include "ClientlessRouteRecorderMgr.h"
#include "DatabaseEnv.h"
#include "BattlefieldMgr.h"
#include "Battlefield.h"
#include "Chat.h"
#include "Config.h"
#include "DBCStores.h"
#include "Log.h"
#include "Map.h"
#include "MoveSplineInit.h"
#include "MoveSpline.h"
#include "PhasingHandler.h"
#include "Player.h"
#include "PathGenerator.h"
#include "Random.h"
#include "SpellInfo.h"
#include "SpellMgr.h"
#include "TerrainMgr.h"
#include "VMapFactory.h"
#include "IVMapManager.h"
#include "VMapManager2.h"
#include "World.h"
#include "WorldSession.h"
#include <algorithm>
#include <cmath>
#include <limits>
#include <sstream>
namespace
{
using namespace ClientlessFlightPath;
Point PointOf(Position const & p)
{
return
{
p.GetPositionX(), p.GetPositionY(), p.GetPositionZ()
};
}
float Infinity()
{
return std::numeric_limits < float >::infinity();
}
bool ClearRay(Player * player, Point const & a, Point const & b)
{
Map * map = player->GetMap();
float x, y, z;
return map->isInLineOfSight(player->GetPhaseShift(), a.X, a.Y, a.Z, b.X, b.Y, b.Z, LINEOFSIGHT_ALL_CHECKS, VMAP::ModelIgnoreFlags(0)) && ! map->getObjectHitPos(player->GetPhaseShift(), a.X, a.Y, a.Z, b.X, b.Y, b.Z, x, y, z, 0);
}
char const * StateName(ClientlessFlightState state)
{
switch (state)
{
case ClientlessFlightState::Mounting : return "Mounting";
case ClientlessFlightState::Flying : return "Flying";
case ClientlessFlightState::Arrived : return "Arrived";
case ClientlessFlightState::Failed : return "Failed";
}
return "Unknown";
}
uint32 FlightType(SpellInfo const * spell)
{
if (spell) for (auto const & effect : spell->Effects) if (effect.ApplyAuraName == SPELL_AURA_MOUNTED) return uint32(effect.MiscValueB);
return 0;
}
}
ClientlessFlightMgr * ClientlessFlightMgr::instance()
{
static ClientlessFlightMgr mgr;
return & mgr;
}
void ClientlessFlightMgr::BeginPlanning()
{
_planningAreas.clear();
_planningTerrain.clear();
}
std::pair < uint32, uint32 > ClientlessFlightMgr::AreaZone(Player const * player, uint32 map, Position const & point) const
{
std::string key = std::to_string(map) + ":" + std::to_string(point.GetPositionX()) + ":" + std::to_string(point.GetPositionY()) + ":" + std::to_string(point.GetPositionZ());
auto found = _planningAreas.find(key);
if (found != _planningAreas.end()) return found->second;
auto & terrain = _planningTerrain[map];
if (! terrain) terrain = sTerrainMgr.LoadTerrain(map);
uint32 zone = 0, area = 0;
if (terrain) terrain->GetZoneAndAreaId(player->GetPhaseShift(), zone, area, map, point);
return _planningAreas.emplace(key, std::make_pair(area, zone)).first->second;
}
bool ClientlessFlightMgr::Enabled(Player const * player) const
{
auto const * profile = sClientlessLuaEngine->GetAdventureProfile(player->GetGUID().GetCounter());
if (! sConfigMgr->GetBoolDefault("Clientless.Flight.Enabled", true) || ! profile || ! profile->CanFreeFly || player->GetSkillValue(SKILL_RIDING) < 225) return false;
for (auto const & mount : profile->FlightMounts) if (player->HasSpell(mount.DisplayId) && FlightType(sSpellMgr->GetSpellInfo(mount.DisplayId))) return true;
return false;
}
bool ClientlessFlightMgr::Preferred(Player const * player, uint32 map, Position const & position) const
{
std::istringstream list(sConfigMgr->GetStringDefault("Clientless.Flight.PreferredZones", "210 4197"));
uint32 zone = AreaZone(player, map, position).second, candidate;
while (list >> candidate) if (zone == candidate) return true;
return false;
}
float ClientlessFlightMgr::Estimate(Player const * player, uint32 map, Position const & from, Position const & to) const
{
auto const * entry = sMapStore.LookupEntry(map);
if (! Enabled(player) || ! entry || entry->MapType != MAP_COMMON) return Infinity();
if ((map == 0 || map == 1) && ! player->HasSpell(90267)) return Infinity();
if (map == 571 && ! player->HasSpell(54197)) return Infinity();
uint32 const area = AreaZone(player, map, from).first;
if (auto const * data = sAreaTableStore.LookupEntry(area))
{
bool const dynamicRules = data->World_pvp_ID != 0 ||(map == player->GetMapId() && area == player->GetAreaId() && player->HasAuraType(SPELL_AURA_MOUNT_RESTRICTIONS));
if (! dynamicRules && ! data->GetMountFlags().HasFlag(AreaMountFlags::AllowFlyingMounts)) return Infinity();
}
// Unknown terrain permission is an estimate only; native mount capability
// and spell checks at the actual departure are mandatory.
bool const preferred = Preferred(player, map, from) || Preferred(player, map, to);
float const distance = Distance(PointOf(from), PointOf(to));
float minimum = preferred ? 60.0f : std::max(100.0f, sConfigMgr->GetFloatDefault("Clientless.Flight.MinDistance", 600));
if (! std::isfinite(distance) || distance < minimum || distance > 20000) return Infinity();
return preferred ? 40 + distance * 0.4f : 180 + distance * 1.5f;
}
ClientlessFlightStatus const * ClientlessFlightMgr::GetStatus(uint32 guid) const
{
auto i = _records.find(guid);
return i == _records.end() ? nullptr : & i->second.Status;
}
void ClientlessFlightMgr::Clear(uint32 guid)
{
_records.erase(guid);
}
Surface ClientlessFlightMgr::Sample(Player * player, Record & record, float x, float y) const
{
Map * map = player->GetMap();
uint32 terrain = PhasingHandler::GetTerrainMapId(player->GetPhaseShift(), map->GetId(), map->GetTerrain(), x, y);
std::string key = std::to_string(terrain) + ":" + std::to_string(int(std::floor(x / 533.33333f))) + ":" + std::to_string(int(std::floor(y / 533.33333f)));
auto found = record.Tiles.find(key);
if (found == record.Tiles.end()) found = record.Tiles.emplace(key, TerrainMgr::ExistMapAndVMap(terrain, x, y)).first;
if (! found->second) return
{
};
map->LoadGrid(x, y);
// terrain/VMAP and dynamic objects before ray queries
float ground = map->GetHeight(player->GetPhaseShift(), x, y, MAX_HEIGHT, true, MAX_HEIGHT);
float water = map->GetWaterLevel(player->GetPhaseShift(), x, y);
if (std::isfinite(water) && water > ground) ground = water;
float ceiling = map->GetTerrain()->GetMaxHeight(player->GetPhaseShift(), map->GetId(), x, y);
return
{
ground, ceiling, std::isfinite(ground) && std::isfinite(ceiling) && ground > INVALID_HEIGHT + 1 && ceiling > ground + 5
};
}
bool ClientlessFlightMgr::Start(Player * player, Position const & target, std::string & error)
{
if (! Trinity::IsValidMapCoord(target.GetPositionX(), target.GetPositionY(), target.GetPositionZ()))
{
error = "invalid free-flight destination coordinates";
return false;
}
if (! player || ! player->IsInWorld() || ! Enabled(player) || ! player->IsAlive() || player->GetMap()->GetEntry()->MapType != MAP_COMMON || player->GetTransport() || player->IsInFlight() || player->IsBeingTeleported() || player->IsInCombat() || ! player->IsOutdoors() || player->IsInWater() || player->IsFalling())
{
error = "free flight unavailable in current player/location state";
return false;
}
auto * vmaps = VMAP::VMapFactory::createOrGetVMapManager();
if (! vmaps->isLineOfSightCalcEnabled() || ! vmaps->isHeightCalcEnabled())
{
error = "free flight requires native VMAP height and collision checks";
return false;
}
if (auto * battlefield = sBattlefieldMgr->GetBattlefieldToZoneId(player->GetMap(), player->GetZoneId())) if (! battlefield->CanFlyIn())
{
error = "battlefield currently forbids flying";
return false;
}
auto const * profile = sClientlessLuaEngine->GetAdventureProfile(player->GetGUID().GetCounter());
std::vector < std::pair < uint32, uint32 >> candidates;
uint32 total = 0;
for (auto const & mount : profile->FlightMounts)
{
if (! player->HasSpell(mount.DisplayId)) continue;
uint32 type = FlightType(sSpellMgr->GetSpellInfo(mount.DisplayId));
auto const * capability = player->GetMountCapability(type);
if (! capability || !(capability->Flags & MOUNT_CAPABILITY_FLAG_FLYING)) continue;
uint32 weight = std::min(mount.Weight, uint32(10000));
total += weight;
candidates.emplace_back(mount.DisplayId, weight);
}
if (! total)
{
error = "no learned profile mount with native flying capability here";
return false;
}
uint32 roll = urand(1, total), spell = 0;
for (auto const & candidate : candidates)
{
spell = candidate.first;
if (roll <= candidate.second) break;
roll -= candidate.second;
}
Record record;
record.Status.MapId = player->GetMapId();
record.Status.Target = target;
record.Start.Relocate(player);
record.LastPosition.Relocate(player);
record.Settings.Clearance = std::clamp(sConfigMgr->GetFloatDefault("Clientless.Flight.Clearance", 60), 20.0f, 200.0f);
auto origin = Sample(player, record, player->GetPositionX(), player->GetPositionY());
if (! origin.Valid || std::fabs(origin.Ground - player->GetPositionZ()) > 3)
{
error = "unknown flight bounds or no safe ground departure";
return false;
}
bool foundLanding = false;
for (float radius :
{
0.0f, 6.0f, 12.0f, 20.0f
})
{
for (unsigned i = 0; i <(radius == 0 ? 1u : 12u); ++ i)
{
float angle = float(i) * 6.283185307f / 12;
float x = target.GetPositionX() + radius * std::cos(angle), y = target.GetPositionY() + radius * std::sin(angle);
auto landing = Sample(player, record, x, y);
if (! landing.Valid || std::fabs(landing.Ground - target.GetPositionZ()) > 5) continue;
float solid = player->GetMap()->GetHeight(player->GetPhaseShift(), x, y, MAX_HEIGHT, true, MAX_HEIGHT);
if (landing.Ground > solid + 0.7f) continue;
// water is no solid touchdown
Point bottom
{
x, y, landing.Ground + 1
}, top = bottom;
top.Z += record.Settings.Clearance;
if (! ClearRay(player, bottom, top)) continue;
Position candidate;
candidate.Relocate(x, y, landing.Ground, target.GetOrientation());
if (radius > 0 || std::fabs(landing.Ground - target.GetPositionZ()) > 0.7f)
{
auto path = PathGenerator::AnalyzePositions(player->GetMapId(), player->GetMapId(), player->GetInstanceId(), candidate, target, NAV_GROUND);
if (! path.IsComplete() || path.FinalDistance > 1.5f || path.PathLength > 120) continue;
}
record.Status.Landing = candidate;
foundLanding = true;
break;
}
if (foundLanding) break;
}
if (! foundLanding)
{
error = "no free solid landing with a validated short target approach";
return false;
}
sClientlessTravelMgr->CancelTravel(player->GetGUID().GetCounter());
player->StopMoving();
sClientlessLuaEngine->DismountGroundMount(player, "free-flight-takeoff");
player->RemoveAurasByType(SPELL_AURA_MOUNTED);
record.Status.State = ClientlessFlightState::Mounting;
record.Status.MountSpell = spell;
record.Status.Detail = "waiting for native flying mount";
_records[player->GetGUID().GetCounter()] = std::move(record);
if (player->CastSpell(player, spell, false) != SPELL_CAST_OK)
{
Fail(player, _records.at(player->GetGUID().GetCounter()), "native mount spell rejected");
error = "native mount spell rejected";
return false;
}
TC_LOG_INFO("server.adventurebot.navigation", "FLIGHT_START guid=%u map=%u spell=%u target=(%.2f %.2f %.2f)", player->GetGUID().GetCounter(), player->GetMapId(), spell, target.GetPositionX(), target.GetPositionY(), target.GetPositionZ());
return true;
}
void ClientlessFlightMgr::Fail(Player * player, Record & record, std::string const & reason)
{
player->StopMoving();
record.Moving = false;
if (record.Status.State == ClientlessFlightState::Mounting) player->InterruptNonMeleeSpells(false);
if (! record.Returning && record.Status.State == ClientlessFlightState::Flying && player->CanFly() && player->IsAlive() && player->GetMapId() == record.Status.MapId && ! record.Breadcrumbs.empty())
{
record.Returning = true;
record.AbortReason = reason;
record.Status.Detail = "aborting: returning along checked flight trail";
TC_LOG_WARN("server.adventurebot.navigation", "FLIGHT_RETURN guid=%u reason='%s'", player->GetGUID().GetCounter(), reason.c_str());
return;
}
record.Status.State = ClientlessFlightState::Failed;
record.Status.Detail = reason;
// If even the reverse corridor is blocked, remain mounted and stopped;
// never fabricate a landing or drop the player from an unknown altitude.
TC_LOG_WARN("server.adventurebot.navigation", "FLIGHT_FAILED guid=%u map=%u pos=(%.2f %.2f %.2f) reason='%s'", player->GetGUID().GetCounter(), player->GetMapId(), player->GetPositionX(), player->GetPositionY(), player->GetPositionZ(), reason.c_str());
}
bool ClientlessFlightMgr::Advance(Player * player, Record & record, bool handoff)
{
Point current = PointOf(* player), goal = PointOf(record.Returning ? record.Start : record.Status.Landing);
auto sample =[&](float x, float y)
{
return Sample(player, record, x, y);
};
auto ray =[&](Point const & a, Point const & b)
{
return ClearRay(player, a, b);
};
if (Horizontal(current, goal) < 1.5f && std::fabs(current.Z - goal.Z) < 0.7f)
{
auto floor = sample(current.X, current.Y);
if (! floor.Valid || std::fabs(current.Z - floor.Ground) > 0.7f)
{
Fail(player, record, "touchdown ground not confirmed");
return false;
}
player->StopMoving();
player->RemoveUnitMovementFlag(MOVEMENTFLAG_FLYING | MOVEMENTFLAG_ASCENDING | MOVEMENTFLAG_DESCENDING);
player->RemoveAurasByType(SPELL_AURA_MOUNTED);
record.Status.State = record.Returning ? ClientlessFlightState::Failed : ClientlessFlightState::Arrived;
record.Status.Detail = record.Returning ? "safely returned after abort: " + record.AbortReason : "landed on validated destination ground";
TC_LOG_INFO("server.adventurebot.navigation", "FLIGHT_LANDED guid=%u segments=%u avoidances=%u travelled=%.2f error=%.2f", player->GetGUID().GetCounter(), record.Status.Segments, record.Status.Avoidances, record.Status.Travelled, Distance(current, goal));
return true;
}
ClientlessFlightPath::Result next;
if (record.Returning)
{
while (! record.Breadcrumbs.empty() && Distance(current, PointOf(record.Breadcrumbs.back())) < 0.7f) record.Breadcrumbs.pop_back();
Point back = record.Breadcrumbs.empty() ? goal : PointOf(record.Breadcrumbs.back());
next =
{
back, Corridor(current, back, goal, record.Settings, sample, ray, true), true, false
};
}
else
{
auto const memory = record.Memory;
next = Next(current, goal, record.Settings, record.Memory, sample, ray);
// Reissuing the same endpoint would only restart its final milliseconds.
// Let that spline finish instead; keep planning memory unchanged.
if (handoff && next.Valid && Distance(next.Next, PointOf(record.Segment)) < 1)
{
record.Memory = memory;
return true;
}
}
if (! next.Valid ||(! record.Returning && record.Status.Travelled > Distance(PointOf(record.Start), goal) * 2 + 500))
{
Fail(player, record, "no safe bounded flight corridor; holding position mounted");
return false;
}
if (! record.Returning) record.Breadcrumbs.push_back(* player);
record.Segment.Relocate(next.Next.X, next.Next.Y, next.Next.Z, player->GetOrientation());
player->AddUnitMovementFlag(MOVEMENTFLAG_FLYING);
Movement::MoveSplineInit init(player);
init.MoveTo(next.Next.X, next.Next.Y, next.Next.Z, false);
init.SetFly();
init.SetWalk(false);
init.SetVelocity(player->GetSpeed(MOVE_FLIGHT));
if (init.Launch() <= 0)
{
Fail(player, record, "flight spline rejected");
return false;
}
record.Moving = true;
record.SegmentElapsed = 0;
++ record.Status.Segments;
record.Status.Avoidances += next.Avoiding ? 1 : 0;
if (handoff) ++record.Status.Handoffs;
record.Status.Detail = next.Avoiding ? "obstacle avoidance" : next.Landing ? "landing approach" : next.Next.Z < current.Z - 0.25f ? "diagonal descent" : next.Next.Z > current.Z + 0.25f ? "diagonal climb" : "terrain-smoothed cruise";
TC_LOG_INFO("server.adventurebot.navigation", "FLIGHT_SEGMENT guid=%u n=%u avoid=%u handoff=%u target=(%.2f %.2f %.2f) detail='%s'", player->GetGUID().GetCounter(), record.Status.Segments, next.Avoiding ? 1 : 0, handoff ? 1 : 0, next.Next.X, next.Next.Y, next.Next.Z, record.Status.Detail.c_str());
return true;
}
void ClientlessFlightMgr::Update(uint32 diff)
{
for (auto &[guid, record] : _records)
{
if (record.Status.State == ClientlessFlightState::Arrived || record.Status.State == ClientlessFlightState::Failed) continue;
auto * session = sWorld->FindClientlessSession(guid);
auto * player = session ? session->GetPlayer() : nullptr;
if (! player || ! player->IsInWorld() || sWorld->GetClientlessPlayerState(guid) != ClientlessPlayerState::Online) continue;
record.Elapsed += std::min(diff, uint32(30000));
record.SegmentElapsed += std::min(diff, uint32(30000));
if (player->GetMapId() != record.Status.MapId || ! player->IsAlive() || player->IsBeingTeleported())
{
Fail(player, record, "flight player/map state changed");
continue;
}
// Count actual motion, not overlapping issued spline lengths.
record.Status.Travelled += Distance(PointOf(record.LastPosition), PointOf(*player));
record.LastPosition.Relocate(player);
if (record.Status.State == ClientlessFlightState::Mounting)
{
if (player->IsMounted() && player->CanFly()) record.Status.State = ClientlessFlightState::Flying;
else
{
if (record.Elapsed > 8000) Fail(player, record, "native mount did not grant flight");
continue;
}
}
if (! player->CanFly() || record.Elapsed >(record.Returning ? 2100000u : 1800000u))
{
Fail(player, record, "flight permission lost or total timeout");
continue;
}
if (record.CheckTimer > diff)
{
record.CheckTimer -= diff;
continue;
}
record.CheckTimer = 200;
if (record.Moving && player->IsSplineEnabled())
{
if (record.SegmentElapsed > 10000)
{
Fail(player, record, "flight segment made no confirmed progress");
continue;
}
auto ray =[&](Point const & a, Point const & b)
{
return ClearRay(player, a, b);
};
auto sample =[&](float x, float y)
{
return Sample(player, record, x, y);
};
// Recheck the remaining corridor as moving GO geometry changes.
if (! Corridor(PointOf(* player), PointOf(record.Segment), PointOf(record.Returning ? record.Start : record.Status.Landing), record.Settings, sample, ray, true))
{
player->StopMoving();
record.Moving = false;
Advance(player, record);
}
else if (!record.Returning && player->movespline->timeRemaining() <= 600 &&
Distance(PointOf(record.Segment), PointOf(record.Status.Landing)) > 0.1f)
{
// Replace a still-running spline from its native current position.
// No StopMoving/arrival packet between ordinary flight segments.
// Landing and conservative breadcrumb return finish their endpoint.
Advance(player, record, true);
}
continue;
}
record.Moving = false;
Advance(player, record);
}
}
bool ClientlessFlightMgr::Command(ChatHandler * handler, char const * args)
{
std::istringstream in(args ? args : "");
std::string op;
uint32 guid = 0;
in >> op >> guid;
auto const * status = GetStatus(guid);
if (op == "abort")
{
auto * session = sWorld->FindClientlessSession(guid);
auto * player = session ? session->GetPlayer() : nullptr;
auto found = _records.find(guid);
if (player && found != _records.end() &&(found->second.Status.State == ClientlessFlightState::Mounting || found->second.Status.State == ClientlessFlightState::Flying))
{
if (found->second.Status.State == ClientlessFlightState::Mounting) player->InterruptNonMeleeSpells(false);
Fail(player, found->second, "operator requested safe return");
handler->SendSysMessage("Flight abort requested. An airborne player attempts the checked reverse trail before dismounting.");
}
else handler->SendSysMessage("No active free flight to abort.");
return true;
}
if (op == "probe")
{
float x, y;
auto * session = sWorld->FindClientlessSession(guid);
auto * player = session ? session->GetPlayer() : nullptr;
if (! player || !(in >> x >> y) || ! std::isfinite(x) || ! std::isfinite(y) || std::fabs(x) > 17000 || std::fabs(y) > 17000)
{
handler->SendSysMessage("Usage: .clientless flight probe <onlineGuid> <x> <y>");
return true;
}
Record probe;
auto surface = Sample(player, probe, x, y);
handler->PSendSysMessage("Flight surface map=%u x=%.3f y=%.3f valid=%u ground=%.3f ceiling=%.3f", player->GetMapId(), x, y, surface.Valid ? 1u : 0u, surface.Ground, surface.Ceiling);
return true;
}
if (op == "test_node")
{
std::string node;
in >> node;
auto * session = sWorld->FindClientlessSession(guid);
auto * player = session ? session->GetPlayer() : nullptr;
auto const * action = sClientlessActionExecutorMgr->GetStatus(guid);
if (! player || ! sClientlessPlayerMgr->IsManaged(guid) || ! ClientlessRouteRecorderMgr::IsSafeKey(node) ||(action &&(action->State == ClientlessActionExecutionState::Moving || action->State == ClientlessActionExecutionState::SelectingTarget)) ||(status &&(status->State == ClientlessFlightState::Mounting || status->State == ClientlessFlightState::Flying)))
{
handler->SendSysMessage("Use an idle online CPC player and a valid destination node.");
return true;
}
QueryResult target = WorldDatabase.PQuery("SELECT w.map,w.position_x,w.position_y,w.position_z FROM vcore_clientless_travel_node n JOIN vcore_clientless_world_point w ON w.point_key=n.world_point_key WHERE n.node_key='%s' AND n.enabled=1 AND w.enabled=1", node.c_str());
QueryResult origin = WorldDatabase.PQuery("SELECT n.node_key FROM vcore_clientless_travel_node n JOIN vcore_clientless_world_point w ON w.point_key=n.world_point_key WHERE n.enabled=1 AND w.enabled=1 AND w.map=%u AND POW(w.position_x-%.6f,2)+POW(w.position_y-%.6f,2)+POW(w.position_z-%.6f,2)<=9 LIMIT 1", player->GetMapId(), player->GetPositionX(), player->GetPositionY(), player->GetPositionZ());
if (! target || ! origin || target->Fetch()[0].GetUInt32() != player->GetMapId())
{
handler->SendSysMessage("Flight test requires a real departure node within 3 yards and a destination node on the same map.");
return true;
}
auto * fields = target->Fetch();
Position point;
point.Relocate(fields[1].GetFloat(), fields[2].GetFloat(), fields[3].GetFloat());
std::string error;
bool ok = Start(player, point, error);
handler->PSendSysMessage("Isolated free-flight test: %s %s", ok ? "started" : "rejected", error.c_str());
return true;
}
if (op != "status" || ! guid)
{
handler->SendSysMessage("Usage: .clientless flight status <guid> OR test_node <guid> <node>. test_node isolates flight execution; normal trips use the planner.");
return false;
}
if (! status) handler->SendSysMessage("No free-flight state for this character.");
else handler->PSendSysMessage("Flight GUID %u: state=%s map=%u mountSpell=%u segments=%u avoidances=%u handoffs=%u travelled=%.1f detail='%s'", guid, StateName(status->State), status->MapId, status->MountSpell, status->Segments, status->Avoidances, status->Handoffs, status->Travelled, status->Detail.c_str());
return true;
}
@@ -0,0 +1,58 @@
#ifndef VCORE_CLIENTLESS_FLIGHT_MGR_H
#define VCORE_CLIENTLESS_FLIGHT_MGR_H
#include "Common.h"
#include "Position.h"
#include "ClientlessFlightPath.h"
#include <string>
#include <memory>
#include <unordered_map>
class Player;
class ChatHandler;
class TerrainInfo;
enum class ClientlessFlightState : uint8
{
Mounting, Flying, Arrived, Failed
};
struct ClientlessFlightStatus
{
ClientlessFlightState State = ClientlessFlightState::Failed;
std::string Detail;
uint32 MapId = 0, MountSpell = 0, Segments = 0, Avoidances = 0, Handoffs = 0;
Position Target, Landing;
float Travelled = 0;
};
class TC_GAME_API ClientlessFlightMgr
{
public : static ClientlessFlightMgr * instance();
// Estimates are transient alternatives in macro planning, not DB edges.
float Estimate(Player const * player, uint32 mapId, Position const & from, Position const & to) const;
bool Preferred(Player const * player, uint32 mapId, Position const & position) const;
bool Enabled(Player const * player) const;
void BeginPlanning();
bool Start(Player * player, Position const & target, std::string & error);
void Update(uint32 diff);
void Clear(uint32 guid);
ClientlessFlightStatus const * GetStatus(uint32 guid) const;
bool Command(ChatHandler * handler, char const * args);
private : struct Record
{
ClientlessFlightStatus Status;
ClientlessFlightPath::Settings Settings;
ClientlessFlightPath::Memory Memory;
Position Start, Segment, LastPosition;
uint32 Elapsed = 0, SegmentElapsed = 0, CheckTimer = 0;
bool Moving = false, Returning = false;
std::string AbortReason;
std::vector < Position > Breadcrumbs;
std::unordered_map < std::string, bool > Tiles;
};
ClientlessFlightPath::Surface Sample(Player * player, Record & record, float x, float y) const;
bool Advance(Player * player, Record & record, bool handoff = false);
void Fail(Player * player, Record & record, std::string const & reason);
std::pair < uint32, uint32 > AreaZone(Player const * player, uint32 map, Position const & point) const;
mutable std::unordered_map < std::string, std::pair < uint32, uint32 >> _planningAreas;
mutable std::unordered_map < uint32, std::shared_ptr < TerrainInfo >> _planningTerrain;
std::unordered_map < uint32, Record > _records;
};
#define sClientlessFlightMgr ClientlessFlightMgr::instance()
#endif
@@ -0,0 +1,214 @@
#ifndef VCORE_CLIENTLESS_FLIGHT_PATH_H
#define VCORE_CLIENTLESS_FLIGHT_PATH_H
#include <algorithm>
#include <array>
#include <cmath>
#include <functional>
#include <limits>
// Geometry-only receding-horizon navigation. Server adapters supply phased
// terrain/ceiling and static+dynamic collision queries. No Player shortcuts.
namespace ClientlessFlightPath
{
struct Point
{
float X = 0, Y = 0, Z = 0;
};
struct Surface
{
float Ground = 0, Ceiling = 0;
bool Valid = false;
};
struct Settings
{
float Clearance = 60, Safety = 8, Radius = 2;
float Step = 25, Lookahead = 100, Slope = 0.6f;
};
struct Memory
{
float LowerTerrainDistance = 0;
float HeadingX = 1, HeadingY = 0;
bool Departed = false;
};
struct Result
{
Point Next;
bool Valid = false, Avoiding = false, Landing = false;
};
using Sample = std::function < Surface(float, float) >;
using Ray = std::function < bool(Point const &, Point const &) >;
inline float Horizontal(Point const & a, Point const & b)
{
return std::hypot(b.X - a.X, b.Y - a.Y);
}
inline float Distance(Point const & a, Point const & b)
{
return std::hypot(Horizontal(a, b), b.Z - a.Z);
}
inline Point Mix(Point const & a, Point const & b, float t)
{
return
{
a.X +(b.X - a.X) * t, a.Y +(b.Y - a.Y) * t, a.Z +(b.Z - a.Z) * t
};
}
inline bool Corridor(Point const & from, Point const & to, Point const & goal, Settings const & cfg, Sample const & sample, Ray const & ray, bool departure = false)
{
int const count = std::max(1, int(std::ceil(Distance(from, to) / 5)));
Surface const origin = sample(from.X, from.Y);
if (! origin.Valid) return false;
for (int i = 0; i <= count; ++ i)
{
Point p = Mix(from, to, float(i) / count);
// Only takeoff/actual target touchdown may approach the surface. This
// allowance follows the real rising/descending slope, not arbitrary terrain.
float margin = cfg.Safety;
if (departure) margin = std::min(margin, std::max(0.0f, from.Z - origin.Ground) + Horizontal(from, p) * std::min(0.3f, std::max(0.0f, to.Z - from.Z) / std::max(1.0f, Horizontal(from, to)) * 0.75f));
margin = std::min(margin, std::max(0.0f, Horizontal(p, goal) * 0.2f));
Surface s = sample(p.X, p.Y);
if (! s.Valid || ! std::isfinite(s.Ground) || ! std::isfinite(s.Ceiling) || p.Z + 0.5f < s.Ground + margin || p.Z + 5 > s.Ceiling) return false;
}
// A volume, not one center ray. Keep the lower rays above foot level so
// a valid ground departure/touchdown does not intersect the ground plane.
for (auto offset : std::array < Point, 5 >
{
{
{
0, 0, 0
},
{
cfg.Radius, 0, 0
},
{
- cfg.Radius, 0, 0
},
{
0, cfg.Radius, 0
},
{
0, - cfg.Radius, 0
}
}
}) for (float height :
{
0.8f, 4.5f
})
{
Point a
{
from.X + offset.X, from.Y + offset.Y, from.Z + height
};
Point b
{
to.X + offset.X, to.Y + offset.Y, to.Z + height
};
if (! ray(a, b)) return false;
}
return true;
}
inline Result Next(Point const & from, Point const & goal, Settings const & cfg, Memory & memory, Sample const & sample, Ray const & ray)
{
float const distance = Horizontal(from, goal);
Surface const here = sample(from.X, from.Y), end = sample(goal.X, goal.Y);
if (! here.Valid || ! end.Valid || std::fabs(end.Ground - goal.Z) > 2.5f) return
{
};
if (distance < 2 && std::fabs(from.Z - goal.Z) < 1.5f) return
{
goal, Corridor(from, goal, goal, cfg, sample, ray, true), false, true
};
float ux = distance > 0.1f ?(goal.X - from.X) / distance : memory.HeadingX;
float uy = distance > 0.1f ?(goal.Y - from.Y) / distance : memory.HeadingY;
if (distance > 0.1f)
{
memory.HeadingX = ux;
memory.HeadingY = uy;
}
std::array < float, 5 > heights;
float ceiling = here.Ceiling;
for (unsigned i = 0; i < heights.size(); ++ i)
{
float d = std::min(distance, float(i) * cfg.Lookahead / 4);
Surface s = sample(from.X + ux * d, from.Y + uy * d);
if (! s.Valid) return
{
};
heights[i] = s.Ground;
ceiling = std::min(ceiling, s.Ceiling);
}
std::sort(heights.begin(), heights.end());
float desired = heights[2] + cfg.Clearance;
// ignore short radical peaks in nominal height
bool const approach = distance < std::max(150.0f,(from.Z - goal.Z) / cfg.Slope + cfg.Step);
if (approach) desired = goal.Z + distance * cfg.Slope * 0.65f;
if (! approach && desired < from.Z - 10)
{
memory.LowerTerrainDistance += cfg.Step;
if (memory.LowerTerrainDistance < 150) desired = from.Z;
}
else memory.LowerTerrainDistance = 0;
float const horizon = std::min(cfg.Lookahead, std::max(cfg.Step, distance));
bool const departure = ! memory.Departed;
if (departure) desired = std::max(desired, here.Ground + 20);
desired = std::min(desired, ceiling - 6);
Result best;
float bestScore = std::numeric_limits < float >::infinity();
// Gentle nominal height tracking is separate from obstacle-climbing
// candidates. Upward avoidance can exceed nominal clearance, never ceiling.
for (float angle :
{
0.0f, 0.45f, - 0.45f, 0.9f, - 0.9f, 1.35f, - 1.35f
}) for (float extraRise :
{
0.0f, - 1.0f, 25.0f, 55.0f
})
{
float dx = ux * std::cos(angle) - uy * std::sin(angle);
float dy = uy * std::cos(angle) + ux * std::sin(angle);
float dz = std::clamp(desired - from.Z, - horizon * cfg.Slope, horizon * cfg.Slope);
if (extraRise < 0) dz = 0;
if (extraRise > 0) dz = std::min(horizon * cfg.Slope, std::max(0.0f, dz) + extraRise);
float run = std::min(horizon, distance);
// If an avoidance climb left us high near the destination, first
// open the approach instead of descending vertically onto the goal.
if (approach && distance < 2 *(from.Z - goal.Z) / cfg.Slope && extraRise == 0)
{
dz = - std::min(horizon * cfg.Slope * 0.7f, std::max(0.0f, from.Z - goal.Z));
if (distance <(from.Z - goal.Z) / cfg.Slope * 0.7f)
{
dx = - dx;
dy = - dy;
run = horizon;
dz = std::min(0.0f, std::max(dz, here.Ground + cfg.Safety + 4 - from.Z));
}
}
Point far
{
from.X + dx * run, from.Y + dy * run, from.Z + dz
};
if (! departure && approach && angle == 0 && extraRise == 0 && distance <= horizon && std::fabs(from.Z - goal.Z) <= distance * cfg.Slope) far = goal;
if (departure && far.Z <= from.Z + 0.5f) continue;
if (Distance(from, far) < 0.5f ||(Horizontal(from, far) < 1 && std::fabs(far.Z - from.Z) > 2)) continue;
if (! Corridor(from, far, goal, cfg, sample, ray, departure)) continue;
float score = Distance(far, goal) + run + std::fabs(angle) * 20 + std::max(0.0f, extraRise) * 0.4f + std::fabs(far.Z - desired) * 0.5f;
bool const openApproach = approach && angle == 0 && extraRise == 0;
if (!openApproach && score >= bestScore) continue;
float t = std::min(1.0f, cfg.Step / std::max(1.0f, Horizontal(from, far)));
best =
{
Mix(from, far, t), true, angle != 0 || extraRise > 0, Distance(far, goal) < 0.1f && t == 1
};
bestScore = score;
// Follow a clear nominal approach immediately. Level-flight scoring
// must not postpone descent until directly above the destination.
if (openApproach)
{
memory.Departed = true;
return best;
}
}
if (best.Valid) memory.Departed = true;
return best;
}
}
#endif
@@ -0,0 +1,556 @@
#include "ClientlessLuaEngine.h"
#include "ClientlessPlayerMgr.h"
#include "ClientlessActionExecutorMgr.h"
#include "ClientlessTravelMgr.h"
#include "Config.h"
#include "Log.h"
#include "Player.h"
#include "Random.h"
#include <lua.hpp>
#include <algorithm>
#include <cctype>
#include <cstdlib>
#include <filesystem>
#include <memory>
#include <string>
#include <unordered_map>
namespace
{
// World-thread only. Script effects are committed only after a successful reload.
struct ReloadEffects
{
std::vector<ClientlessAction> Actions;
ClientlessIntent Intent;
bool IntentChanged = false;
};
ReloadEffects* pendingReload = nullptr;
uint32 BoundGuid(lua_State* L) { return uint32(lua_tointeger(L, lua_upvalueindex(1))); }
std::unordered_map<std::string, std::string> ReadMap(lua_State* L, int index)
{
std::unordered_map<std::string, std::string> out;
if (!lua_istable(L, index)) return out;
index = lua_absindex(L, index);
lua_pushnil(L);
while (lua_next(L, index) != 0)
{
if (lua_type(L, -2) == LUA_TSTRING)
{
std::string key = lua_tostring(L, -2), value;
switch (lua_type(L, -1))
{
case LUA_TSTRING: value = lua_tostring(L, -1); break;
case LUA_TNUMBER:
value = lua_isinteger(L, -1) ? std::to_string(lua_tointeger(L, -1)) : std::to_string(lua_tonumber(L, -1));
break;
case LUA_TBOOLEAN: value = lua_toboolean(L, -1) ? "true" : "false"; break;
default: break;
}
if (!value.empty()) out.emplace(std::move(key), std::move(value));
}
lua_pop(L, 1);
}
return out;
}
int LuaIntend(lua_State* L)
{
ClientlessIntent intent;
intent.Name = luaL_checkstring(L, 1);
if (lua_gettop(L) >= 2) intent.Parameters = ReadMap(L, 2);
if (pendingReload)
{
bool valid = !intent.Empty();
if (valid)
{
pendingReload->Intent = std::move(intent);
pendingReload->IntentChanged = true;
}
lua_pushboolean(L, valid);
return 1;
}
lua_pushboolean(L, sClientlessPlayerMgr->SetIntent(BoundGuid(L), std::move(intent)));
return 1;
}
int LuaClearIntent(lua_State* L)
{
if (pendingReload)
{
pendingReload->Intent = ClientlessIntent();
pendingReload->IntentChanged = true;
lua_pushboolean(L, true);
return 1;
}
lua_pushboolean(L, sClientlessPlayerMgr->ClearIntent(BoundGuid(L)));
return 1;
}
int LuaQueueAction(lua_State* L)
{
ClientlessAction action;
action.Name = luaL_checkstring(L, 1);
if (lua_gettop(L) >= 2) action.Parameters = ReadMap(L, 2);
if (pendingReload)
{
bool valid = !action.Name.empty();
if (valid) pendingReload->Actions.push_back(std::move(action));
lua_pushboolean(L, valid);
return 1;
}
lua_pushboolean(L, sClientlessPlayerMgr->QueueAction(BoundGuid(L), std::move(action)));
return 1;
}
int LuaCharacterGuid(lua_State* L)
{
lua_pushinteger(L, BoundGuid(L));
return 1;
}
int LuaLog(lua_State* L)
{
TC_LOG_INFO("server.worldserver", "ClientlessLua[%u]: %s", BoundGuid(L), luaL_checkstring(L, 1));
return 0;
}
void BoundFn(lua_State* L, int env, char const* name, lua_CFunction fn, uint32 guid)
{
env = lua_absindex(L, env);
lua_pushinteger(L, guid);
lua_pushcclosure(L, fn, 1);
lua_setfield(L, env, name);
}
std::string ReadStringField(lua_State* L, int table, char const* key, std::string const& fallback = {})
{
table = lua_absindex(L, table);
lua_getfield(L, table, key);
std::string value = lua_isstring(L, -1) ? lua_tostring(L, -1) : fallback;
lua_pop(L, 1);
return value;
}
bool ReadBoolField(lua_State* L, int table, char const* key, bool fallback)
{
table = lua_absindex(L, table);
lua_getfield(L, table, key);
bool value = lua_isboolean(L, -1) ? lua_toboolean(L, -1) != 0 : fallback;
lua_pop(L, 1);
return value;
}
float ReadFloatField(lua_State* L, int table, char const* key, float fallback)
{
table = lua_absindex(L, table);
lua_getfield(L, table, key);
float value = lua_isnumber(L, -1) ? float(lua_tonumber(L, -1)) : fallback;
lua_pop(L, 1);
return value;
}
std::unordered_map<std::string, float> ReadFloatDictionary(lua_State* L, int table, char const* key)
{
std::unordered_map<std::string, float> out;
table = lua_absindex(L, table);
lua_getfield(L, table, key);
if (!lua_istable(L, -1))
{
lua_pop(L, 1);
return out;
}
int dict = lua_absindex(L, -1);
lua_pushnil(L);
while (lua_next(L, dict) != 0)
{
if (lua_type(L, -2) == LUA_TSTRING && lua_isnumber(L, -1))
out[lua_tostring(L, -2)] = float(lua_tonumber(L, -1));
lua_pop(L, 1);
}
lua_pop(L, 1);
return out;
}
std::vector<ClientlessWeightedMount> ReadMountDictionary(lua_State* L, int movementTable, char const* field = "ground_mounts")
{
std::vector<ClientlessWeightedMount> mounts;
movementTable = lua_absindex(L, movementTable);
lua_getfield(L, movementTable, field);
if (!lua_istable(L, -1))
{
lua_pop(L, 1);
return mounts;
}
int dict = lua_absindex(L, -1);
lua_pushnil(L);
while (lua_next(L, dict) != 0)
{
if (lua_isinteger(L, -2) && lua_isnumber(L, -1))
{
lua_Integer id = lua_tointeger(L, -2);
lua_Number weight = lua_tonumber(L, -1);
if (id > 0 && id <= 0xFFFFFFFFll && weight > 0.0)
mounts.push_back({ uint32(id), uint32(std::max<lua_Number>(1.0, weight)) });
}
lua_pop(L, 1);
}
lua_pop(L, 1);
return mounts;
}
class ClientlessAdventureLuaController final : public ClientlessPlayerController
{
public:
void OnPlayerOnline(Player* player) override
{
sClientlessLuaEngine->LoadAdventureProfile(player);
sClientlessLuaEngine->ExecuteAdventureScript(player);
}
};
}
ClientlessLuaEngine* ClientlessLuaEngine::instance()
{
static ClientlessLuaEngine instance;
return &instance;
}
ClientlessLuaEngine::ClientlessLuaEngine()
{
_scriptRoot = sConfigMgr->GetStringDefault("Clientless.ScriptRoot", "scripts/clientless");
if (char const* overrideRoot = std::getenv("VCORE_CLIENTLESS_SCRIPT_DIR"))
if (*overrideRoot) _scriptRoot = overrideRoot;
_scriptRoot = std::filesystem::absolute(_scriptRoot).lexically_normal().string();
_state = luaL_newstate();
if (!_state)
{
TC_LOG_ERROR("server.worldserver", "ClientlessLua: failed to create Lua state.");
return;
}
luaL_openlibs(_state);
for (char const* g : { "io", "os", "package", "debug", "dofile", "loadfile", "require" })
{
lua_pushnil(_state);
lua_setglobal(_state, g);
}
TC_LOG_INFO("server.worldserver", "ClientlessLua initialized, script root '%s'.", _scriptRoot.c_str());
}
ClientlessLuaEngine::~ClientlessLuaEngine()
{
if (_state) lua_close(_state);
}
void ClientlessLuaEngine::PushSandboxEnvironment(Player* player)
{
lua_newtable(_state);
int env = lua_gettop(_state);
lua_newtable(_state);
lua_pushglobaltable(_state);
lua_setfield(_state, -2, "__index");
lua_setmetatable(_state, env);
uint32 guid = player->GetGUID().GetCounter();
BoundFn(_state, env, "intend", LuaIntend, guid);
BoundFn(_state, env, "clear_intent", LuaClearIntent, guid);
BoundFn(_state, env, "queue_action", LuaQueueAction, guid);
BoundFn(_state, env, "character_guid", LuaCharacterGuid, guid);
BoundFn(_state, env, "log", LuaLog, guid);
lua_pushstring(_state, player->GetName().c_str());
lua_setfield(_state, env, "character_name");
}
bool ClientlessLuaEngine::ExecuteScript(Player* player, std::string const& path, std::string const& kind)
{
if (!_state || !player) return false;
int base = lua_gettop(_state);
if (luaL_loadfile(_state, path.c_str()) != LUA_OK)
{
TC_LOG_ERROR("server.worldserver", "ClientlessLua[%u]: load failed for %s '%s': %s",
player->GetGUID().GetCounter(), kind.c_str(), path.c_str(), lua_tostring(_state, -1));
lua_settop(_state, base);
return false;
}
PushSandboxEnvironment(player);
if (!lua_setupvalue(_state, base + 1, 1))
{
TC_LOG_ERROR("server.worldserver", "ClientlessLua[%u]: '%s' has no _ENV upvalue.", player->GetGUID().GetCounter(), path.c_str());
lua_settop(_state, base);
return false;
}
if (lua_pcall(_state, 0, 0, 0) != LUA_OK)
{
TC_LOG_ERROR("server.worldserver", "ClientlessLua[%u]: runtime error in '%s': %s",
player->GetGUID().GetCounter(), path.c_str(), lua_tostring(_state, -1));
lua_settop(_state, base);
return false;
}
TC_LOG_INFO("server.worldserver", "ClientlessLua[%u]: executed %s script '%s'.",
player->GetGUID().GetCounter(), kind.c_str(), path.c_str());
lua_settop(_state, base);
return true;
}
bool ClientlessLuaEngine::LoadAdventureProfile(Player* player)
{
if (!_state || !player)
return false;
namespace fs = std::filesystem;
std::string lowerName = player->GetName();
std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(),
[](unsigned char c) { return char(std::tolower(c)); });
fs::path root(_scriptRoot);
fs::path specific = root / "adventure" / "profiles" / (lowerName + ".lua");
fs::path generic = root / "adventure" / "profiles" / "generic.lua";
fs::path chosen = fs::exists(specific) ? specific : generic;
int base = lua_gettop(_state);
if (luaL_loadfile(_state, chosen.string().c_str()) != LUA_OK)
{
TC_LOG_ERROR("server.adventurebot",
"[AdventureBot][Profile] guid=%u name='%s' load failed path='%s': %s",
player->GetGUID().GetCounter(), player->GetName().c_str(),
chosen.string().c_str(), lua_tostring(_state, -1));
lua_settop(_state, base);
return false;
}
PushSandboxEnvironment(player);
if (!lua_setupvalue(_state, base + 1, 1))
{
TC_LOG_ERROR("server.adventurebot",
"[AdventureBot][Profile] guid=%u path='%s' has no _ENV upvalue",
player->GetGUID().GetCounter(), chosen.string().c_str());
lua_settop(_state, base);
return false;
}
if (lua_pcall(_state, 0, 1, 0) != LUA_OK)
{
TC_LOG_ERROR("server.adventurebot",
"[AdventureBot][Profile] guid=%u runtime error path='%s': %s",
player->GetGUID().GetCounter(), chosen.string().c_str(), lua_tostring(_state, -1));
lua_settop(_state, base);
return false;
}
if (!lua_istable(_state, -1))
{
TC_LOG_ERROR("server.adventurebot",
"[AdventureBot][Profile] guid=%u path='%s' must return a table",
player->GetGUID().GetCounter(), chosen.string().c_str());
lua_settop(_state, base);
return false;
}
int rootTable = lua_absindex(_state, -1);
ClientlessAdventureProfile profile;
profile.CharacterName = player->GetName();
profile.SourcePath = chosen.string();
lua_getfield(_state, rootTable, "identity");
if (lua_istable(_state, -1))
{
profile.CharacterName = ReadStringField(_state, -1, "name", profile.CharacterName);
profile.HomeRegion = ReadStringField(_state, -1, "home_region");
}
lua_pop(_state, 1);
lua_getfield(_state, rootTable, "movement");
if (lua_istable(_state, -1))
{
profile.CanGroundMount = ReadBoolField(_state, -1, "can_ground_mount", false);
profile.GroundMountMinDistance = std::max(0.0f,
ReadFloatField(_state, -1, "ground_mount_min_distance", 60.0f));
profile.GroundMountSpeedRate = std::max(1.0f,
ReadFloatField(_state, -1, "ground_mount_speed_rate", 1.6f));
profile.GroundMounts = ReadMountDictionary(_state, -1);
profile.CanFreeFly = ReadBoolField(_state, -1, "can_free_fly", false);
profile.FlightMounts = ReadMountDictionary(_state, -1, "flight_mounts");
}
lua_pop(_state, 1);
profile.Personality = ReadFloatDictionary(_state, rootTable, "personality");
profile.RegionAffinity = ReadFloatDictionary(_state, rootTable, "region_affinity");
profile.ActivityAffinity = ReadFloatDictionary(_state, rootTable, "activity_affinity");
uint32 const guid = player->GetGUID().GetCounter();
_profiles[guid] = std::move(profile);
ClientlessAdventureProfile const& stored = _profiles.at(guid);
TC_LOG_INFO("server.adventurebot",
"[AdventureBot][Profile] loaded guid=%u name='%s' source='%s' home='%s' "
"canGroundMount=%u mounts=%u mountMinDistance=%.1f mountSpeedRate=%.2f "
"personality=%u regionAffinity=%u activityAffinity=%u",
guid, player->GetName().c_str(), stored.SourcePath.c_str(), stored.HomeRegion.c_str(),
stored.CanGroundMount ? 1u : 0u, uint32(stored.GroundMounts.size()),
stored.GroundMountMinDistance, stored.GroundMountSpeedRate,
uint32(stored.Personality.size()), uint32(stored.RegionAffinity.size()),
uint32(stored.ActivityAffinity.size()));
if (stored.CanGroundMount && stored.GroundMounts.empty())
TC_LOG_WARN("server.adventurebot",
"[AdventureBot][Profile] guid=%u enables ground mounts but defines no ground_mounts", guid);
lua_settop(_state, base);
return true;
}
ClientlessAdventureProfile const* ClientlessLuaEngine::GetAdventureProfile(uint32 characterGuid) const
{
auto itr = _profiles.find(characterGuid);
return itr != _profiles.end() ? &itr->second : nullptr;
}
uint32 ClientlessLuaEngine::SelectGroundMountDisplayId(uint32 characterGuid) const
{
ClientlessAdventureProfile const* profile = GetAdventureProfile(characterGuid);
if (!profile || profile->GroundMounts.empty())
return 0;
uint64 total = 0;
for (ClientlessWeightedMount const& mount : profile->GroundMounts)
total += mount.Weight;
if (!total)
return 0;
uint32 const cappedTotal = uint32(std::min<uint64>(total, 0xFFFFFFFFull));
uint32 roll = urand(1u, cappedTotal);
for (ClientlessWeightedMount const& mount : profile->GroundMounts)
{
if (roll <= mount.Weight)
return mount.DisplayId;
roll -= std::min(roll, mount.Weight);
}
return profile->GroundMounts.back().DisplayId;
}
bool ClientlessLuaEngine::PrepareGroundMount(Player* player, float travelDistance, char const* context)
{
if (!player || !player->IsInWorld())
return false;
ClientlessAdventureProfile const* profile = GetAdventureProfile(player->GetGUID().GetCounter());
if (!profile || !profile->CanGroundMount || profile->GroundMounts.empty())
return false;
if (travelDistance < profile->GroundMountMinDistance || !player->IsOutdoors())
return false;
if (player->IsMounted())
return true;
uint32 const displayId = SelectGroundMountDisplayId(player->GetGUID().GetCounter());
if (!displayId)
return false;
// The AdventureBot profile is authoritative for this capability. Intentionally
// no level, riding-skill, spellbook, inventory or economy eligibility check.
player->Mount(displayId);
player->SetSpeedRate(MOVE_RUN, profile->GroundMountSpeedRate);
TC_LOG_INFO("server.adventurebot.navigation",
"GROUND_MOUNT guid=%u name='%s' displayId=%u distance=%.2f speedRate=%.2f context='%s'",
player->GetGUID().GetCounter(), player->GetName().c_str(), displayId,
travelDistance, profile->GroundMountSpeedRate, context ? context : "");
return true;
}
void ClientlessLuaEngine::DismountGroundMount(Player* player, char const* reason)
{
if (!player || !player->IsMounted())
return;
uint32 const oldDisplay = player->GetMountID();
player->Dismount();
player->UpdateSpeed(MOVE_RUN);
TC_LOG_INFO("server.adventurebot.navigation",
"GROUND_DISMOUNT guid=%u name='%s' displayId=%u reason='%s'",
player->GetGUID().GetCounter(), player->GetName().c_str(), oldDisplay,
reason ? reason : "");
}
bool ClientlessLuaEngine::ExecuteAdventureScript(Player* player)
{
if (!player) return false;
namespace fs = std::filesystem;
std::string name = player->GetName();
std::transform(name.begin(), name.end(), name.begin(), [](unsigned char c) { return char(std::tolower(c)); });
fs::path root(_scriptRoot);
fs::path custom = root / "adventure" / "overrides" / (name + ".lua");
if (fs::exists(custom)) return ExecuteScript(player, custom.string(), "adventure override");
return ExecuteScript(player, (root / "adventure" / "generic.lua").string(), "generic adventure");
}
bool ClientlessLuaEngine::ReloadAdventureScript(Player* player, std::string& detail)
{
if (!player || !player->IsInWorld() || !sClientlessPlayerMgr->IsManaged(player->GetGUID().GetCounter()))
{
detail = "Reload requires an online managed clientless player.";
return false;
}
uint32 guid = player->GetGUID().GetCounter();
auto action = sClientlessActionExecutorMgr->GetStatus(guid);
auto travel = sClientlessTravelMgr->GetStatus(guid);
if (pendingReload || sClientlessPlayerMgr->GetQueuedActionCount(guid) ||
(action && (action->State == ClientlessActionExecutionState::SelectingTarget ||
action->State == ClientlessActionExecutionState::Moving)) ||
(travel && travel->State == ClientlessTravelState::Moving) ||
player->GetTransport() || player->IsInFlight() || player->IsBeingTeleported())
{
detail = "Reload refused: bot has queued actions, is travelling or is aboard a transport. Wait until idle.";
return false;
}
auto previous = _profiles.find(guid);
bool hadProfile = previous != _profiles.end();
ClientlessAdventureProfile backup = hadProfile ? previous->second : ClientlessAdventureProfile();
ReloadEffects effects;
bool success;
{
struct Capture
{
explicit Capture(ReloadEffects& effects) { pendingReload = &effects; }
~Capture() { pendingReload = nullptr; }
} capture(effects);
success = LoadAdventureProfile(player) && ExecuteAdventureScript(player);
}
if (!success)
{
if (hadProfile) _profiles[guid] = std::move(backup);
else _profiles.erase(guid);
detail = "Lua reload failed. Previous profile retained; no new actions or intent applied. See server log.";
}
else
{
if (effects.IntentChanged)
{
if (effects.Intent.Empty()) sClientlessPlayerMgr->ClearIntent(guid);
else sClientlessPlayerMgr->SetIntent(guid, std::move(effects.Intent));
}
for (auto& queued : effects.Actions)
sClientlessPlayerMgr->QueueAction(guid, std::move(queued));
detail = "Profile and adventure script reloaded; queued actions=" + std::to_string(effects.Actions.size()) + ".";
}
TC_LOG_INFO("server.adventurebot.navigation", "LUA_RELOAD guid=%u success=%u root='%s' detail='%s'",
guid, success ? 1u : 0u, _scriptRoot.c_str(), detail.c_str());
return success;
}
bool ClientlessLuaEngine::ExecuteTestScript(Player* player, std::string const& scriptName)
{
if (!player || scriptName.empty() || scriptName.find("..") != std::string::npos || scriptName.find('/') != std::string::npos || scriptName.find('\\') != std::string::npos)
return false;
std::filesystem::path path = std::filesystem::path(_scriptRoot) / "tests" / (scriptName + ".lua");
return ExecuteScript(player, path.string(), "test");
}
std::unique_ptr<ClientlessPlayerController> CreateClientlessAdventureLuaController()
{
return std::make_unique<ClientlessAdventureLuaController>();
}
@@ -0,0 +1,74 @@
/* Vcore C7: server-side Lua for clientless Players. */
#ifndef VCORE_CLIENTLESS_LUA_ENGINE_H
#define VCORE_CLIENTLESS_LUA_ENGINE_H
#include "Define.h"
#include <memory>
#include <string>
#include <unordered_map>
#include <vector>
class ClientlessPlayerController;
class Player;
struct lua_State;
// CLIENTLESS_PLAYER_CORE_C8_12_ADVENTURE_PROFILE
// Lua describes who a performative AdventureBot is; generic C++ systems decide
// how those values become visible behavior.
struct TC_GAME_API ClientlessWeightedMount
{
uint32 DisplayId = 0;
uint32 Weight = 0;
};
struct TC_GAME_API ClientlessAdventureProfile
{
std::string CharacterName;
std::string HomeRegion;
std::string SourcePath;
bool CanGroundMount = false;
float GroundMountMinDistance = 60.0f;
float GroundMountSpeedRate = 1.6f;
std::vector<ClientlessWeightedMount> GroundMounts;
bool CanFreeFly = false;
// Flight dictionaries use learned mount SPELL IDs, never display IDs.
std::vector<ClientlessWeightedMount> FlightMounts;
// Parsed now; consumed later by the weighted intent/day planner.
std::unordered_map<std::string, float> Personality;
std::unordered_map<std::string, float> RegionAffinity;
std::unordered_map<std::string, float> ActivityAffinity;
};
class TC_GAME_API ClientlessLuaEngine
{
public:
static ClientlessLuaEngine* instance();
~ClientlessLuaEngine();
bool ExecuteAdventureScript(Player* player);
bool ExecuteTestScript(Player* player, std::string const& scriptName);
bool ReloadAdventureScript(Player* player, std::string& detail);
bool LoadAdventureProfile(Player* player);
ClientlessAdventureProfile const* GetAdventureProfile(uint32 characterGuid) const;
uint32 SelectGroundMountDisplayId(uint32 characterGuid) const;
bool PrepareGroundMount(Player* player, float travelDistance, char const* context);
void DismountGroundMount(Player* player, char const* reason);
std::string const& GetScriptRoot() const { return _scriptRoot; }
private:
ClientlessLuaEngine();
bool ExecuteScript(Player* player, std::string const& path, std::string const& kind);
void PushSandboxEnvironment(Player* player);
lua_State* _state = nullptr;
std::string _scriptRoot;
std::unordered_map<uint32, ClientlessAdventureProfile> _profiles;
};
TC_GAME_API std::unique_ptr<ClientlessPlayerController> CreateClientlessAdventureLuaController();
#define sClientlessLuaEngine ClientlessLuaEngine::instance()
#endif
@@ -0,0 +1,301 @@
#include "ClientlessPerceptionMgr.h"
#include "CellImpl.h"
#include "ClientlessPlayerMgr.h"
#include "Creature.h"
#include "GridNotifiersImpl.h"
#include "Player.h"
#include "SharedDefines.h"
#include "World.h"
#include "WorldSession.h"
#include <algorithm>
#include <list>
#include <sstream>
namespace
{
constexpr float DEFAULT_SCAN_RADIUS = 40.0f;
constexpr uint32 SCAN_INTERVAL_MS = 1000;
ClientlessEntityRelation RelationFromReaction(ReputationRank reaction)
{
if (reaction <= REP_HOSTILE)
return ClientlessEntityRelation::Hostile;
if (reaction >= REP_FRIENDLY)
return ClientlessEntityRelation::Friendly;
return ClientlessEntityRelation::Neutral;
}
}
ClientlessPerceptionMgr* ClientlessPerceptionMgr::instance()
{
static ClientlessPerceptionMgr instance;
return &instance;
}
char const* ClientlessPerceptionMgr::TypeName(ClientlessEntityType type)
{
switch (type)
{
case ClientlessEntityType::Creature: return "Creature";
case ClientlessEntityType::Player: return "Player";
}
return "Unknown";
}
char const* ClientlessPerceptionMgr::RelationName(ClientlessEntityRelation relation)
{
switch (relation)
{
case ClientlessEntityRelation::Unknown: return "unknown";
case ClientlessEntityRelation::Friendly: return "friendly";
case ClientlessEntityRelation::Neutral: return "neutral";
case ClientlessEntityRelation::Hostile: return "hostile";
}
return "unknown";
}
std::string ClientlessPerceptionMgr::CapabilitiesToString(uint64 capabilities)
{
std::ostringstream out;
bool first = true;
auto add = [&](uint64 flag, char const* name)
{
if ((capabilities & flag) == 0)
return;
if (!first)
out << ',';
out << name;
first = false;
};
add(CLIENTLESS_CAP_VENDOR, "vendor");
add(CLIENTLESS_CAP_TRAINER, "trainer");
add(CLIENTLESS_CAP_FLIGHTMASTER, "flightmaster");
add(CLIENTLESS_CAP_BANKER, "banker");
add(CLIENTLESS_CAP_INNKEEPER, "innkeeper");
add(CLIENTLESS_CAP_AUCTIONEER, "auctioneer");
add(CLIENTLESS_CAP_QUESTGIVER, "questgiver");
add(CLIENTLESS_CAP_GOSSIP, "gossip");
add(CLIENTLESS_CAP_REPAIR, "repair");
add(CLIENTLESS_CAP_BATTLEMASTER, "battlemaster");
add(CLIENTLESS_CAP_PETITIONER, "petitioner");
add(CLIENTLESS_CAP_SPIRITHEALER, "spirithealer");
add(CLIENTLESS_CAP_SPIRITGUIDE, "spiritguide");
add(CLIENTLESS_CAP_CRITTER, "critter");
add(CLIENTLESS_CAP_CLIENTLESS, "clientless");
return first ? "-" : out.str();
}
ClientlessEntityRelation ClientlessPerceptionMgr::DetermineRelation(Player* observer, Player* target)
{
if (!observer || !target)
return ClientlessEntityRelation::Unknown;
return RelationFromReaction(observer->GetReactionTo(target));
}
ClientlessEntityRelation ClientlessPerceptionMgr::DetermineRelation(Player* observer, Creature* target)
{
if (!observer || !target)
return ClientlessEntityRelation::Unknown;
return RelationFromReaction(observer->GetReactionTo(target));
}
bool ClientlessPerceptionMgr::ScanNow(Player* player, float radius)
{
if (!player || !player->IsInWorld())
return false;
radius = std::max(1.0f, std::min(radius, 100.0f));
uint32 const characterGuid = player->GetGUID().GetCounter();
std::list<WorldObject*> objects;
Trinity::AllWorldObjectsInRange check(player, radius);
Trinity::WorldObjectListSearcher<Trinity::AllWorldObjectsInRange> searcher(player, objects, check);
Cell::VisitAllObjects(player, searcher, radius);
ClientlessPerceptionSnapshot snapshot;
snapshot.Radius = radius;
snapshot.ScanAgeMs = 0;
for (WorldObject* object : objects)
{
if (!object)
continue;
if (Creature* creature = object->ToCreature())
{
ClientlessPerceivedEntity entity;
entity.Type = ClientlessEntityType::Creature;
entity.Guid = creature->GetGUID().GetCounter();
entity.Entry = creature->GetEntry();
entity.Name = creature->GetName();
entity.Distance = player->GetDistance(creature);
entity.MapId = creature->GetMapId();
entity.PositionX = creature->GetPositionX();
entity.PositionY = creature->GetPositionY();
entity.PositionZ = creature->GetPositionZ();
entity.Orientation = creature->GetOrientation();
entity.Level = creature->getLevel();
entity.Health = creature->GetHealth();
entity.MaxHealth = creature->GetMaxHealth();
entity.Alive = creature->IsAlive();
entity.InLineOfSight = player->IsWithinLOSInMap(creature);
entity.Attackable = player->IsValidAttackTarget(creature);
entity.Relation = DetermineRelation(player, creature);
uint64 capabilities = CLIENTLESS_CAP_NONE;
if (creature->IsVendor()) capabilities |= CLIENTLESS_CAP_VENDOR;
if (creature->IsTrainer()) capabilities |= CLIENTLESS_CAP_TRAINER;
if (creature->IsTaxi()) capabilities |= CLIENTLESS_CAP_FLIGHTMASTER;
if (creature->IsBanker()) capabilities |= CLIENTLESS_CAP_BANKER;
if (creature->IsInnkeeper()) capabilities |= CLIENTLESS_CAP_INNKEEPER;
if (creature->IsAuctioner()) capabilities |= CLIENTLESS_CAP_AUCTIONEER;
if (creature->IsQuestGiver()) capabilities |= CLIENTLESS_CAP_QUESTGIVER;
if (creature->IsGossip()) capabilities |= CLIENTLESS_CAP_GOSSIP;
if (creature->IsArmorer()) capabilities |= CLIENTLESS_CAP_REPAIR;
if (creature->IsBattleMaster()) capabilities |= CLIENTLESS_CAP_BATTLEMASTER;
if (creature->IsGuildMaster()) capabilities |= CLIENTLESS_CAP_PETITIONER;
if (creature->IsSpiritHealer()) capabilities |= CLIENTLESS_CAP_SPIRITHEALER;
if (creature->IsSpiritGuide()) capabilities |= CLIENTLESS_CAP_SPIRITGUIDE;
if (creature->IsCritter()) capabilities |= CLIENTLESS_CAP_CRITTER;
entity.Capabilities = capabilities;
snapshot.Entities.push_back(std::move(entity));
continue;
}
if (Player* otherPlayer = object->ToPlayer())
{
if (otherPlayer == player)
continue;
ClientlessPerceivedEntity entity;
entity.Type = ClientlessEntityType::Player;
entity.Guid = otherPlayer->GetGUID().GetCounter();
entity.Name = otherPlayer->GetName();
entity.Distance = player->GetDistance(otherPlayer);
entity.MapId = otherPlayer->GetMapId();
entity.PositionX = otherPlayer->GetPositionX();
entity.PositionY = otherPlayer->GetPositionY();
entity.PositionZ = otherPlayer->GetPositionZ();
entity.Orientation = otherPlayer->GetOrientation();
entity.Level = otherPlayer->getLevel();
entity.Health = otherPlayer->GetHealth();
entity.MaxHealth = otherPlayer->GetMaxHealth();
entity.Alive = otherPlayer->IsAlive();
entity.InLineOfSight = player->IsWithinLOSInMap(otherPlayer);
entity.Attackable = player->IsValidAttackTarget(otherPlayer);
entity.Relation = DetermineRelation(player, otherPlayer);
if (sClientlessPlayerMgr->IsManaged(entity.Guid))
entity.Capabilities |= CLIENTLESS_CAP_CLIENTLESS;
snapshot.Entities.push_back(std::move(entity));
}
}
std::sort(snapshot.Entities.begin(), snapshot.Entities.end(),
[](ClientlessPerceivedEntity const& a, ClientlessPerceivedEntity const& b)
{
return a.Distance < b.Distance;
});
Record& record = _records[characterGuid];
record.Snapshot = std::move(snapshot);
record.ScanTimerMs = SCAN_INTERVAL_MS;
return true;
}
ClientlessPerceptionSnapshot const* ClientlessPerceptionMgr::GetSnapshot(uint32 characterGuid) const
{
auto itr = _records.find(characterGuid);
return itr != _records.end() ? &itr->second.Snapshot : nullptr;
}
bool ClientlessPerceptionMgr::Matches(
ClientlessPerceivedEntity const& entity,
ClientlessPerceptionQuery const& query)
{
if (query.FilterType && entity.Type != query.Type)
return false;
if (query.FilterRelation && entity.Relation != query.Relation)
return false;
if (query.RequiredCapabilities &&
(entity.Capabilities & query.RequiredCapabilities) != query.RequiredCapabilities)
return false;
if (query.ForbiddenCapabilities &&
(entity.Capabilities & query.ForbiddenCapabilities) != 0)
return false;
if (query.RequireAlive && !entity.Alive)
return false;
if (query.RequireAttackable && !entity.Attackable)
return false;
if (query.RequireLineOfSight && !entity.InLineOfSight)
return false;
if (query.MaxDistance > 0.0f && entity.Distance > query.MaxDistance)
return false;
return true;
}
ClientlessPerceivedEntity const* ClientlessPerceptionMgr::FindNearest(
uint32 characterGuid,
ClientlessPerceptionQuery const& query) const
{
ClientlessPerceptionSnapshot const* snapshot = GetSnapshot(characterGuid);
if (!snapshot)
return nullptr;
for (ClientlessPerceivedEntity const& entity : snapshot->Entities)
if (Matches(entity, query))
return &entity;
return nullptr;
}
void ClientlessPerceptionMgr::Update(uint32 diff)
{
for (auto const& [characterGuid, session] : sWorld->GetAllClientlessSessions())
{
if (!session || !sClientlessPlayerMgr->IsManaged(characterGuid))
continue;
Player* player = session->GetPlayer();
if (!player || !player->IsInWorld())
continue;
Record& record = _records[characterGuid];
record.Snapshot.ScanAgeMs += diff;
if (record.ScanTimerMs > diff)
{
record.ScanTimerMs -= diff;
continue;
}
ScanNow(
player,
record.Snapshot.Radius > 0.0f
? record.Snapshot.Radius
: DEFAULT_SCAN_RADIUS);
}
}
@@ -0,0 +1,147 @@
/*
* Vcore AdventureBot local perception.
*
* C8.3 final model:
* - generic perceived entities (Creature / Player)
* - data-driven capability bitmask derived from existing Core/DB-backed flags
* - explicit relation vs. attackability
* - generic FindNearest(query) API
*/
#ifndef VCORE_CLIENTLESS_PERCEPTION_MGR_H
#define VCORE_CLIENTLESS_PERCEPTION_MGR_H
#include "Common.h"
#include <string>
#include <unordered_map>
#include <vector>
class Player;
class Creature;
enum class ClientlessEntityType : uint8
{
Creature,
Player
};
enum class ClientlessEntityRelation : uint8
{
Unknown,
Friendly,
Neutral,
Hostile
};
enum ClientlessEntityCapability : uint64
{
CLIENTLESS_CAP_NONE = 0,
CLIENTLESS_CAP_VENDOR = 1ULL << 0,
CLIENTLESS_CAP_TRAINER = 1ULL << 1,
CLIENTLESS_CAP_FLIGHTMASTER = 1ULL << 2,
CLIENTLESS_CAP_BANKER = 1ULL << 3,
CLIENTLESS_CAP_INNKEEPER = 1ULL << 4,
CLIENTLESS_CAP_AUCTIONEER = 1ULL << 5,
CLIENTLESS_CAP_QUESTGIVER = 1ULL << 6,
CLIENTLESS_CAP_GOSSIP = 1ULL << 7,
CLIENTLESS_CAP_REPAIR = 1ULL << 8,
CLIENTLESS_CAP_BATTLEMASTER = 1ULL << 9,
CLIENTLESS_CAP_PETITIONER = 1ULL << 10,
CLIENTLESS_CAP_SPIRITHEALER = 1ULL << 11,
CLIENTLESS_CAP_SPIRITGUIDE = 1ULL << 12,
CLIENTLESS_CAP_CRITTER = 1ULL << 13,
CLIENTLESS_CAP_CLIENTLESS = 1ULL << 14
};
struct TC_GAME_API ClientlessPerceivedEntity
{
ClientlessEntityType Type = ClientlessEntityType::Creature;
uint32 Guid = 0;
uint32 Entry = 0;
std::string Name;
float Distance = 0.0f;
uint32 MapId = 0;
float PositionX = 0.0f;
float PositionY = 0.0f;
float PositionZ = 0.0f;
float Orientation = 0.0f;
uint8 Level = 0;
uint32 Health = 0;
uint32 MaxHealth = 0;
bool Alive = false;
bool InLineOfSight = false;
bool Attackable = false;
ClientlessEntityRelation Relation = ClientlessEntityRelation::Unknown;
uint64 Capabilities = CLIENTLESS_CAP_NONE;
bool HasCapability(uint64 capability) const
{
return (Capabilities & capability) == capability;
}
};
struct TC_GAME_API ClientlessPerceptionSnapshot
{
float Radius = 0.0f;
uint32 ScanAgeMs = 0;
std::vector<ClientlessPerceivedEntity> Entities;
};
struct TC_GAME_API ClientlessPerceptionQuery
{
bool FilterType = false;
ClientlessEntityType Type = ClientlessEntityType::Creature;
bool FilterRelation = false;
ClientlessEntityRelation Relation = ClientlessEntityRelation::Unknown;
uint64 RequiredCapabilities = CLIENTLESS_CAP_NONE;
uint64 ForbiddenCapabilities = CLIENTLESS_CAP_NONE;
bool RequireAlive = false;
bool RequireAttackable = false;
bool RequireLineOfSight = false;
// 0 = use the whole current snapshot.
float MaxDistance = 0.0f;
};
class TC_GAME_API ClientlessPerceptionMgr
{
public:
static ClientlessPerceptionMgr* instance();
void Update(uint32 diff);
bool ScanNow(Player* player, float radius = 40.0f);
ClientlessPerceptionSnapshot const* GetSnapshot(uint32 characterGuid) const;
ClientlessPerceivedEntity const* FindNearest(
uint32 characterGuid,
ClientlessPerceptionQuery const& query) const;
static char const* TypeName(ClientlessEntityType type);
static char const* RelationName(ClientlessEntityRelation relation);
static std::string CapabilitiesToString(uint64 capabilities);
private:
struct Record
{
ClientlessPerceptionSnapshot Snapshot;
uint32 ScanTimerMs = 0;
};
static ClientlessEntityRelation DetermineRelation(Player* observer, Player* target);
static ClientlessEntityRelation DetermineRelation(Player* observer, Creature* target);
static bool Matches(ClientlessPerceivedEntity const& entity, ClientlessPerceptionQuery const& query);
std::unordered_map<uint32, Record> _records;
};
#define sClientlessPerceptionMgr ClientlessPerceptionMgr::instance()
#endif
@@ -0,0 +1,197 @@
#include "ClientlessPlayerMgr.h"
#include "ClientlessTravelMgr.h"
#include "ClientlessTransportMgr.h"
#include "ClientlessPerceptionMgr.h"
#include "ClientlessActionExecutorMgr.h"
#include "ClientlessFlightMgr.h"
#include "Log.h"
#include "Player.h"
#include "World.h"
#include "WorldSession.h"
ClientlessPlayerMgr* ClientlessPlayerMgr::instance()
{
static ClientlessPlayerMgr instance;
return &instance;
}
bool ClientlessPlayerMgr::StartPlayer(uint32 accountId, uint32 characterGuid,
std::unique_ptr<ClientlessPlayerController> controller)
{
if (!accountId || !characterGuid)
return false;
if (IsManaged(characterGuid) ||
sWorld->GetClientlessPlayerState(characterGuid) != ClientlessPlayerState::Offline)
{
TC_LOG_ERROR("server.worldserver",
"ClientlessPlayerMgr::StartPlayer rejected character GUID %u because it is already managed or not Offline.",
characterGuid);
return false;
}
if (!controller)
controller = std::make_unique<ClientlessPlayerController>();
ManagedPlayer record;
record.AccountId = accountId;
record.Controller = std::move(controller);
auto inserted = _records.emplace(characterGuid, std::move(record));
if (!inserted.second)
return false;
if (!sWorld->CreateClientlessPlayerSession(accountId, characterGuid))
{
_records.erase(characterGuid);
return false;
}
TC_LOG_INFO("server.worldserver",
"ClientlessPlayerMgr started character GUID %u (account %u).",
characterGuid, accountId);
return true;
}
bool ClientlessPlayerMgr::StopPlayer(uint32 characterGuid)
{
if (_records.find(characterGuid) == _records.end())
return false;
return sWorld->RemoveClientlessSession(characterGuid);
}
bool ClientlessPlayerMgr::IsManaged(uint32 characterGuid) const
{
return _records.find(characterGuid) != _records.end();
}
uint32 ClientlessPlayerMgr::GetAccountId(uint32 characterGuid) const
{
auto itr = _records.find(characterGuid);
return itr != _records.end() ? itr->second.AccountId : 0u;
}
bool ClientlessPlayerMgr::SetIntent(uint32 characterGuid, ClientlessIntent intent)
{
auto itr = _records.find(characterGuid);
if (itr == _records.end() || intent.Empty())
return false;
itr->second.Intent = std::move(intent);
if (WorldSession* session = sWorld->FindClientlessSession(characterGuid))
if (Player* player = session->GetPlayer())
if (itr->second.Controller)
itr->second.Controller->OnIntentChanged(player, &itr->second.Intent);
return true;
}
bool ClientlessPlayerMgr::ClearIntent(uint32 characterGuid)
{
auto itr = _records.find(characterGuid);
if (itr == _records.end())
return false;
itr->second.Intent = ClientlessIntent();
if (WorldSession* session = sWorld->FindClientlessSession(characterGuid))
if (Player* player = session->GetPlayer())
if (itr->second.Controller)
itr->second.Controller->OnIntentChanged(player, nullptr);
return true;
}
ClientlessIntent const* ClientlessPlayerMgr::GetIntent(uint32 characterGuid) const
{
auto itr = _records.find(characterGuid);
if (itr == _records.end() || itr->second.Intent.Empty())
return nullptr;
return &itr->second.Intent;
}
bool ClientlessPlayerMgr::QueueAction(uint32 characterGuid, ClientlessAction action)
{
auto itr = _records.find(characterGuid);
if (itr == _records.end() || action.Name.empty())
return false;
itr->second.Actions.push_back(std::move(action));
return true;
}
bool ClientlessPlayerMgr::PopNextAction(uint32 characterGuid, ClientlessAction& action)
{
auto itr = _records.find(characterGuid);
if (itr == _records.end() || itr->second.Actions.empty())
return false;
action = std::move(itr->second.Actions.front());
itr->second.Actions.pop_front();
return true;
}
uint32 ClientlessPlayerMgr::GetQueuedActionCount(uint32 characterGuid) const
{
auto itr = _records.find(characterGuid);
return itr != _records.end() ? uint32(itr->second.Actions.size()) : 0u;
}
std::deque<ClientlessAction> const* ClientlessPlayerMgr::GetQueuedActions(uint32 characterGuid) const
{
auto itr = _records.find(characterGuid);
return itr != _records.end() ? &itr->second.Actions : nullptr;
}
void ClientlessPlayerMgr::Update(uint32 diff)
{
// CLIENTLESS_PLAYER_CORE_C8_2_TRAVEL_ROBUSTNESS
sClientlessTravelMgr->Update(diff);
// CLIENTLESS_PLAYER_CORE_C8_3_LOCAL_PERCEPTION
sClientlessPerceptionMgr->Update(diff);
// CLIENTLESS_PLAYER_CORE_C8_4_ACTION_EXECUTOR
sClientlessTransportMgr->Update(diff);
sClientlessFlightMgr->Update(diff);
sClientlessActionExecutorMgr->Update(diff);
for (auto itr = _records.begin(); itr != _records.end(); )
{
uint32 characterGuid = itr->first;
ManagedPlayer& record = itr->second;
ClientlessPlayerState state = sWorld->GetClientlessPlayerState(characterGuid);
if (state == ClientlessPlayerState::Offline)
{
sClientlessActionExecutorMgr->Clear(characterGuid);
sClientlessFlightMgr->Clear(characterGuid);
itr = _records.erase(itr);
continue;
}
WorldSession* session = sWorld->FindClientlessSession(characterGuid);
Player* player = session ? session->GetPlayer() : nullptr;
if (state == ClientlessPlayerState::Online && player)
{
if (!record.OnlineNotified)
{
record.OnlineNotified = true;
if (record.Controller)
{
record.Controller->OnPlayerOnline(player);
if (!record.Intent.Empty())
record.Controller->OnIntentChanged(player, &record.Intent);
}
}
if (record.Controller)
record.Controller->Update(player, diff);
}
else if (state == ClientlessPlayerState::Removing && !record.LogoutNotified)
{
record.LogoutNotified = true;
if (record.Controller && player)
record.Controller->OnPlayerLogout(player);
}
++itr;
}
}
@@ -0,0 +1,78 @@
/*
* Vcore generic clientless Player manager.
* C6 contains no AdventureBot policy and no Lua runtime.
*/
#ifndef VCORE_CLIENTLESS_PLAYER_MGR_H
#define VCORE_CLIENTLESS_PLAYER_MGR_H
#include "Common.h"
#include <deque>
#include <memory>
#include <string>
#include <unordered_map>
class Player;
struct TC_GAME_API ClientlessIntent
{
std::string Name;
std::unordered_map<std::string, std::string> Parameters;
bool Empty() const { return Name.empty(); }
};
struct TC_GAME_API ClientlessAction
{
std::string Name;
std::unordered_map<std::string, std::string> Parameters;
};
class TC_GAME_API ClientlessPlayerController
{
public:
virtual ~ClientlessPlayerController() = default;
virtual void OnPlayerOnline(Player*) { }
virtual void OnIntentChanged(Player*, ClientlessIntent const*) { }
virtual void Update(Player*, uint32) { }
virtual void OnPlayerLogout(Player*) { }
};
class TC_GAME_API ClientlessPlayerMgr
{
public:
static ClientlessPlayerMgr* instance();
bool StartPlayer(uint32 accountId, uint32 characterGuid,
std::unique_ptr<ClientlessPlayerController> controller = nullptr);
bool StopPlayer(uint32 characterGuid);
bool IsManaged(uint32 characterGuid) const;
uint32 GetAccountId(uint32 characterGuid) const;
uint32 GetManagedCount() const { return uint32(_records.size()); }
bool SetIntent(uint32 characterGuid, ClientlessIntent intent);
bool ClearIntent(uint32 characterGuid);
ClientlessIntent const* GetIntent(uint32 characterGuid) const;
bool QueueAction(uint32 characterGuid, ClientlessAction action);
bool PopNextAction(uint32 characterGuid, ClientlessAction& action);
uint32 GetQueuedActionCount(uint32 characterGuid) const;
std::deque<ClientlessAction> const* GetQueuedActions(uint32 characterGuid) const;
void Update(uint32 diff);
private:
struct ManagedPlayer
{
uint32 AccountId = 0;
std::unique_ptr<ClientlessPlayerController> Controller;
ClientlessIntent Intent;
std::deque<ClientlessAction> Actions;
bool OnlineNotified = false;
bool LogoutNotified = false;
};
std::unordered_map<uint32, ManagedPlayer> _records;
};
#define sClientlessPlayerMgr ClientlessPlayerMgr::instance()
#endif
@@ -0,0 +1,698 @@
#include "ClientlessRouteRecorderMgr.h"
#include "ClientlessTravelGraphMgr.h"
#include "DatabaseEnv.h"
#include "ObjectAccessor.h"
#include "Player.h"
#include <algorithm>
#include <cmath>
ClientlessRouteRecorderMgr* ClientlessRouteRecorderMgr::instance()
{
static ClientlessRouteRecorderMgr instance;
return &instance;
}
bool ClientlessRouteRecorderMgr::IsSafeKey(std::string const& value)
{
if (value.empty() || value.size() > 100)
return false;
for (char c : value)
if (!(c >= 'a' && c <= 'z') &&
!(c >= 'A' && c <= 'Z') &&
!(c >= '0' && c <= '9') &&
c != '_' && c != '-' && c != '.')
return false;
return true;
}
char const* ClientlessRouteRecorderMgr::StateName(ClientlessRouteRecorderState state)
{
switch (state)
{
case ClientlessRouteRecorderState::Inactive: return "inactive";
case ClientlessRouteRecorderState::Recording: return "recording";
case ClientlessRouteRecorderState::Paused: return "paused";
}
return "unknown";
}
bool ClientlessRouteRecorderMgr::LoadNode(std::string const& key, NodeLocation& node) const
{
QueryResult result = WorldDatabase.PQuery(
"SELECT w.map, w.position_x, w.position_y, w.position_z, w.orientation "
"FROM vcore_clientless_travel_node n "
"INNER JOIN vcore_clientless_world_point w ON w.point_key=n.world_point_key "
"WHERE n.node_key='%s' AND n.enabled=1 AND w.enabled=1 LIMIT 1",
key.c_str());
if (!result)
return false;
Field* f = result->Fetch();
node.MapId = f[0].GetUInt16();
node.X = f[1].GetFloat();
node.Y = f[2].GetFloat();
node.Z = f[3].GetFloat();
node.O = f[4].GetFloat();
return true;
}
float ClientlessRouteRecorderMgr::Distance(
ClientlessRecordedRoutePoint const& a,
ClientlessRecordedRoutePoint const& b) const
{
float dx = a.X - b.X;
float dy = a.Y - b.Y;
float dz = a.Z - b.Z;
return std::sqrt(dx * dx + dy * dy + dz * dz);
}
float ClientlessRouteRecorderMgr::Distance(Player const* player, NodeLocation const& node) const
{
float dx = player->GetPositionX() - node.X;
float dy = player->GetPositionY() - node.Y;
float dz = player->GetPositionZ() - node.Z;
return std::sqrt(dx * dx + dy * dy + dz * dz);
}
void ClientlessRouteRecorderMgr::RecalculateLength()
{
_length = 0.0f;
for (std::size_t i = 1; i < _points.size(); ++i)
_length += Distance(_points[i - 1], _points[i]);
}
bool ClientlessRouteRecorderMgr::AppendCurrent(Player* player, bool force)
{
if (!player || !player->IsInWorld())
return false;
ClientlessRecordedRoutePoint point;
point.MapId = player->GetMapId();
point.X = player->GetPositionX();
point.Y = player->GetPositionY();
point.Z = player->GetPositionZ();
point.O = player->GetOrientation();
if (!_points.empty())
{
if (_points.back().MapId != point.MapId)
return false;
float distance = Distance(_points.back(), point);
if (!force && distance < RECORD_INTERVAL_YARDS)
return false;
_length += distance;
}
_points.push_back(point);
return true;
}
bool ClientlessRouteRecorderMgr::AddNode(
Player* player, std::string const& key, std::string const& type, std::string& error)
{
if (!player || !player->IsInWorld())
{
error = "player is not in world";
return false;
}
if (!IsSafeKey(key) || !IsSafeKey(type))
{
error = "key/type may contain only A-Z a-z 0-9 _ - .";
return false;
}
if (WorldDatabase.PQuery(
"SELECT id FROM vcore_clientless_travel_node WHERE node_key='%s' LIMIT 1",
key.c_str()))
{
error = "node already exists; use node move to change its position";
return false;
}
if (WorldDatabase.PQuery(
"SELECT id FROM vcore_clientless_world_point WHERE point_key='%s' LIMIT 1",
key.c_str()))
{
error = "world point with the same key already exists";
return false;
}
WorldDatabase.DirectPExecute(
"INSERT INTO vcore_clientless_world_point "
"(point_key,category,map,position_x,position_y,position_z,orientation,enabled,comment) "
"VALUES ('%s','travel',%u,%.9f,%.9f,%.9f,%.9f,1,'Travel node recorded in game')",
key.c_str(),
player->GetMapId(),
player->GetPositionX(),
player->GetPositionY(),
player->GetPositionZ(),
player->GetOrientation());
WorldDatabase.DirectPExecute(
"INSERT INTO vcore_clientless_travel_node "
"(node_key,node_type,world_point_key,enabled,comment) "
"VALUES ('%s','%s','%s',1,'Recorded in game')",
key.c_str(), type.c_str(), key.c_str());
sClientlessTravelGraphMgr->Reload();
return true;
}
bool ClientlessRouteRecorderMgr::MoveNode(
Player* player, std::string const& key, std::string& error)
{
if (!player || !player->IsInWorld())
{
error = "player is not in world";
return false;
}
if (!IsSafeKey(key))
{
error = "invalid node key";
return false;
}
QueryResult result = WorldDatabase.PQuery(
"SELECT world_point_key FROM vcore_clientless_travel_node WHERE node_key='%s' LIMIT 1",
key.c_str());
if (!result)
{
error = "unknown node";
return false;
}
std::string worldPointKey = result->Fetch()[0].GetString();
if (!IsSafeKey(worldPointKey))
{
error = "node references unsupported world point key";
return false;
}
WorldDatabase.DirectPExecute(
"UPDATE vcore_clientless_world_point "
"SET map=%u,position_x=%.9f,position_y=%.9f,position_z=%.9f,orientation=%.9f "
"WHERE point_key='%s'",
player->GetMapId(),
player->GetPositionX(),
player->GetPositionY(),
player->GetPositionZ(),
player->GetOrientation(),
worldPointKey.c_str());
sClientlessTravelGraphMgr->Reload();
return true;
}
bool ClientlessRouteRecorderMgr::RemoveNode(std::string const& key, std::string& error)
{
if (!IsSafeKey(key))
{
error = "invalid node key";
return false;
}
QueryResult nodeResult = WorldDatabase.PQuery(
"SELECT world_point_key FROM vcore_clientless_travel_node WHERE node_key='%s' LIMIT 1",
key.c_str());
if (!nodeResult)
{
error = "unknown node";
return false;
}
QueryResult edgeResult = WorldDatabase.PQuery(
"SELECT id FROM vcore_clientless_travel_edge "
"WHERE from_node_key='%s' OR to_node_key='%s' LIMIT 1",
key.c_str(), key.c_str());
if (edgeResult)
{
error = "node still has travel edges; remove/rework those first";
return false;
}
std::string worldPointKey = nodeResult->Fetch()[0].GetString();
WorldDatabase.DirectPExecute(
"DELETE FROM vcore_clientless_travel_node WHERE node_key='%s'",
key.c_str());
if (IsSafeKey(worldPointKey))
WorldDatabase.DirectPExecute(
"DELETE FROM vcore_clientless_world_point WHERE point_key='%s'",
worldPointKey.c_str());
sClientlessTravelGraphMgr->Reload();
return true;
}
bool ClientlessRouteRecorderMgr::ForceRemoveNode(
std::string const& key, uint32& removedEdges, std::string& error)
{
removedEdges = 0;
if (!IsSafeKey(key))
{
error = "invalid node key";
return false;
}
QueryResult nodeResult = WorldDatabase.PQuery(
"SELECT world_point_key FROM vcore_clientless_travel_node WHERE node_key='%s' LIMIT 1",
key.c_str());
if (!nodeResult)
{
error = "unknown node";
return false;
}
std::string worldPointKey = nodeResult->Fetch()[0].GetString();
if (QueryResult countResult = WorldDatabase.PQuery(
"SELECT COUNT(*) FROM vcore_clientless_travel_edge "
"WHERE from_node_key='%s' OR to_node_key='%s'",
key.c_str(), key.c_str()))
removedEdges = countResult->Fetch()[0].GetUInt32();
WorldDatabase.DirectPExecute(
"DELETE ep FROM vcore_clientless_travel_edge_point ep "
"INNER JOIN vcore_clientless_travel_edge e ON e.id=ep.edge_id "
"WHERE e.from_node_key='%s' OR e.to_node_key='%s'",
key.c_str(), key.c_str());
WorldDatabase.DirectPExecute(
"DELETE FROM vcore_clientless_travel_edge "
"WHERE from_node_key='%s' OR to_node_key='%s'",
key.c_str(), key.c_str());
WorldDatabase.DirectPExecute(
"DELETE FROM vcore_clientless_travel_node WHERE node_key='%s'",
key.c_str());
if (IsSafeKey(worldPointKey))
WorldDatabase.DirectPExecute(
"DELETE FROM vcore_clientless_world_point WHERE point_key='%s'",
worldPointKey.c_str());
sClientlessTravelGraphMgr->Reload();
return true;
}
bool ClientlessRouteRecorderMgr::RemoveRoute(
std::string const& fromNode,
std::string const& toNode,
uint32& removedEdges,
std::string& error)
{
removedEdges = 0;
if (!IsSafeKey(fromNode) || !IsSafeKey(toNode) || fromNode == toNode)
{
error = "invalid route endpoints";
return false;
}
QueryResult edgeResult = WorldDatabase.PQuery(
"SELECT id FROM vcore_clientless_travel_edge "
"WHERE ((from_node_key='%s' AND to_node_key='%s') OR "
"(from_node_key='%s' AND to_node_key='%s')) "
"ORDER BY id",
fromNode.c_str(), toNode.c_str(), toNode.c_str(), fromNode.c_str());
if (!edgeResult)
{
error = "no route exists between those nodes";
return false;
}
std::vector<uint32> edgeIds;
do
edgeIds.push_back(edgeResult->Fetch()[0].GetUInt32());
while (edgeResult->NextRow());
for (uint32 edgeId : edgeIds)
{
WorldDatabase.DirectPExecute(
"DELETE FROM vcore_clientless_travel_edge_point WHERE edge_id=%u",
edgeId);
WorldDatabase.DirectPExecute(
"DELETE FROM vcore_clientless_travel_edge WHERE id=%u",
edgeId);
}
removedEdges = uint32(edgeIds.size());
sClientlessTravelGraphMgr->Reload();
return true;
}
bool ClientlessRouteRecorderMgr::Start(
Player* player, std::string const& fromNode, std::string const& toNode, std::string& error)
{
if (_state != ClientlessRouteRecorderState::Inactive)
{
error = "a route recording is already active";
return false;
}
if (!player || !player->IsInWorld())
{
error = "player is not in world";
return false;
}
if (!IsSafeKey(fromNode) || !IsSafeKey(toNode) || fromNode == toNode)
{
error = "invalid route endpoints";
return false;
}
if (!LoadNode(fromNode, _fromLocation))
{
error = "unknown from-node: " + fromNode;
return false;
}
if (!LoadNode(toNode, _toLocation))
{
error = "unknown to-node: " + toNode;
return false;
}
if (_fromLocation.MapId != _toLocation.MapId)
{
error = "ground route endpoints are on different maps";
return false;
}
if (player->GetMapId() != _fromLocation.MapId ||
Distance(player, _fromLocation) > NODE_ENDPOINT_RADIUS_YARDS)
{
error = "stand within 20 yd of the from-node before recording";
return false;
}
_recorderGuid = player->GetGUID();
_fromNode = fromNode;
_toNode = toNode;
_points.clear();
_length = 0.0f;
_sampleTimer = 0;
ClientlessRecordedRoutePoint first;
first.MapId = _fromLocation.MapId;
first.X = _fromLocation.X;
first.Y = _fromLocation.Y;
first.Z = _fromLocation.Z;
first.O = _fromLocation.O;
_points.push_back(first);
_state = ClientlessRouteRecorderState::Recording;
return true;
}
bool ClientlessRouteRecorderMgr::Pause(Player* player, std::string& error)
{
if (_state != ClientlessRouteRecorderState::Recording)
{
error = "route recorder is not recording";
return false;
}
if (!player || player->GetGUID() != _recorderGuid)
{
error = "this player is not the active route recorder";
return false;
}
_state = ClientlessRouteRecorderState::Paused;
return true;
}
bool ClientlessRouteRecorderMgr::Resume(Player* player, std::string& error)
{
if (_state != ClientlessRouteRecorderState::Paused)
{
error = "route recorder is not paused";
return false;
}
if (!player || player->GetGUID() != _recorderGuid)
{
error = "this player is not the active route recorder";
return false;
}
if (player->GetMapId() != _fromLocation.MapId)
{
error = "recorder is on a different map";
return false;
}
AppendCurrent(player, true);
_state = ClientlessRouteRecorderState::Recording;
_sampleTimer = 0;
return true;
}
bool ClientlessRouteRecorderMgr::Cut(
Player* player, uint32& keptPoints, float& nearestDistance, std::string& error)
{
keptPoints = 0;
nearestDistance = 0.0f;
if (_state != ClientlessRouteRecorderState::Paused)
{
error = "pause the route before using cut";
return false;
}
if (!player || player->GetGUID() != _recorderGuid || _points.empty())
{
error = "invalid recorder player/state";
return false;
}
if (player->GetMapId() != _fromLocation.MapId)
{
error = "recorder is on a different map";
return false;
}
std::size_t best = 0;
float bestDistance = 999999.0f;
ClientlessRecordedRoutePoint current;
current.MapId = player->GetMapId();
current.X = player->GetPositionX();
current.Y = player->GetPositionY();
current.Z = player->GetPositionZ();
current.O = player->GetOrientation();
for (std::size_t i = 0; i < _points.size(); ++i)
{
float d = Distance(_points[i], current);
if (d < bestDistance)
{
bestDistance = d;
best = i;
}
}
_points.resize(best + 1);
if (bestDistance > 1.0f)
_points.push_back(current);
RecalculateLength();
keptPoints = uint32(_points.size());
nearestDistance = bestDistance;
return true;
}
bool ClientlessRouteRecorderMgr::Mark(Player* player, std::string& error)
{
if (_state == ClientlessRouteRecorderState::Inactive)
{
error = "no active route recording";
return false;
}
if (!player || player->GetGUID() != _recorderGuid)
{
error = "this player is not the active route recorder";
return false;
}
if (!AppendCurrent(player, true))
{
error = "could not append route point";
return false;
}
return true;
}
bool ClientlessRouteRecorderMgr::Stop(
Player* player, uint32& savedPoints, std::string& error)
{
savedPoints = 0;
if (_state == ClientlessRouteRecorderState::Inactive)
{
error = "no active route recording";
return false;
}
if (!player || player->GetGUID() != _recorderGuid)
{
error = "this player is not the active route recorder";
return false;
}
if (player->GetMapId() != _toLocation.MapId ||
Distance(player, _toLocation) > NODE_ENDPOINT_RADIUS_YARDS)
{
error = "stand within 20 yd of the to-node before stopping";
return false;
}
ClientlessRecordedRoutePoint last;
last.MapId = _toLocation.MapId;
last.X = _toLocation.X;
last.Y = _toLocation.Y;
last.Z = _toLocation.Z;
last.O = _toLocation.O;
if (_points.empty() || Distance(_points.back(), last) > 0.25f)
_points.push_back(last);
RecalculateLength();
QueryResult oldEdge = WorldDatabase.PQuery(
"SELECT id FROM vcore_clientless_travel_edge "
"WHERE edge_type='ground' AND "
"((from_node_key='%s' AND to_node_key='%s') OR "
"(from_node_key='%s' AND to_node_key='%s')) "
"ORDER BY id LIMIT 1",
_fromNode.c_str(), _toNode.c_str(), _toNode.c_str(), _fromNode.c_str());
if (oldEdge)
{
uint32 oldId = oldEdge->Fetch()[0].GetUInt32();
WorldDatabase.DirectPExecute(
"DELETE FROM vcore_clientless_travel_edge_point WHERE edge_id=%u",
oldId);
WorldDatabase.DirectPExecute(
"DELETE FROM vcore_clientless_travel_edge WHERE id=%u",
oldId);
}
WorldDatabase.DirectPExecute(
"INSERT INTO vcore_clientless_travel_edge "
"(from_node_key,to_node_key,edge_type,cost,bidirectional,enabled,comment) "
"VALUES ('%s','%s','ground',%.3f,1,1,'Recorded in game')",
_fromNode.c_str(), _toNode.c_str(),
std::max(1.0f, _length));
QueryResult newEdge = WorldDatabase.PQuery(
"SELECT id FROM vcore_clientless_travel_edge "
"WHERE from_node_key='%s' AND to_node_key='%s' AND edge_type='ground' "
"ORDER BY id DESC LIMIT 1",
_fromNode.c_str(), _toNode.c_str());
if (!newEdge)
{
error = "edge insert succeeded but edge id could not be resolved";
return false;
}
uint32 edgeId = newEdge->Fetch()[0].GetUInt32();
for (uint32 i = 0; i < _points.size(); ++i)
{
ClientlessRecordedRoutePoint const& p = _points[i];
WorldDatabase.DirectPExecute(
"INSERT INTO vcore_clientless_travel_edge_point "
"(edge_id,point_index,map,position_x,position_y,position_z,orientation) "
"VALUES (%u,%u,%u,%.9f,%.9f,%.9f,%.9f)",
edgeId, i, p.MapId, p.X, p.Y, p.Z, p.O);
}
savedPoints = uint32(_points.size());
_state = ClientlessRouteRecorderState::Inactive;
_recorderGuid.Clear();
_fromNode.clear();
_toNode.clear();
_points.clear();
_length = 0.0f;
_sampleTimer = 0;
sClientlessTravelGraphMgr->Reload();
return true;
}
void ClientlessRouteRecorderMgr::Cancel()
{
_state = ClientlessRouteRecorderState::Inactive;
_recorderGuid.Clear();
_fromNode.clear();
_toNode.clear();
_points.clear();
_length = 0.0f;
_sampleTimer = 0;
}
ClientlessRouteRecorderStatus ClientlessRouteRecorderMgr::GetStatus() const
{
ClientlessRouteRecorderStatus status;
status.State = _state;
status.FromNode = _fromNode;
status.ToNode = _toNode;
status.PointCount = uint32(_points.size());
status.Length = _length;
return status;
}
void ClientlessRouteRecorderMgr::Update(uint32 diff)
{
if (_state != ClientlessRouteRecorderState::Recording)
return;
if (_sampleTimer > diff)
{
_sampleTimer -= diff;
return;
}
_sampleTimer = SAMPLE_INTERVAL_MS;
Player* player = ObjectAccessor::FindConnectedPlayer(_recorderGuid);
if (!player || !player->IsInWorld())
{
_state = ClientlessRouteRecorderState::Paused;
return;
}
if (player->GetMapId() != _fromLocation.MapId)
{
_state = ClientlessRouteRecorderState::Paused;
return;
}
AppendCurrent(player, false);
}
@@ -0,0 +1,102 @@
/*
* Vcore AdventureBot route recorder.
*
* Nodes are independent graph objects. Ground routes are recorded later
* between two existing nodes and are bidirectional by default.
*/
#ifndef VCORE_CLIENTLESS_ROUTE_RECORDER_MGR_H
#define VCORE_CLIENTLESS_ROUTE_RECORDER_MGR_H
#include "Common.h"
#include "ObjectGuid.h"
#include <string>
#include <vector>
class Player;
struct TC_GAME_API ClientlessRecordedRoutePoint
{
uint32 MapId = 0;
float X = 0.0f;
float Y = 0.0f;
float Z = 0.0f;
float O = 0.0f;
};
enum class ClientlessRouteRecorderState : uint8
{
Inactive,
Recording,
Paused
};
struct TC_GAME_API ClientlessRouteRecorderStatus
{
ClientlessRouteRecorderState State = ClientlessRouteRecorderState::Inactive;
std::string FromNode;
std::string ToNode;
uint32 PointCount = 0;
float Length = 0.0f;
};
class TC_GAME_API ClientlessRouteRecorderMgr
{
public:
static ClientlessRouteRecorderMgr* instance();
void Update(uint32 diff);
bool AddNode(Player* player, std::string const& key, std::string const& type, std::string& error);
bool MoveNode(Player* player, std::string const& key, std::string& error);
bool RemoveNode(std::string const& key, std::string& error);
bool ForceRemoveNode(std::string const& key, uint32& removedEdges, std::string& error);
bool RemoveRoute(std::string const& fromNode, std::string const& toNode, uint32& removedEdges, std::string& error);
bool Start(Player* player, std::string const& fromNode, std::string const& toNode, std::string& error);
bool Pause(Player* player, std::string& error);
bool Resume(Player* player, std::string& error);
bool Cut(Player* player, uint32& keptPoints, float& nearestDistance, std::string& error);
bool Mark(Player* player, std::string& error);
bool Stop(Player* player, uint32& savedPoints, std::string& error);
void Cancel();
ClientlessRouteRecorderStatus GetStatus() const;
static bool IsSafeKey(std::string const& value);
static char const* StateName(ClientlessRouteRecorderState state);
private:
struct NodeLocation
{
uint32 MapId = 0;
float X = 0.0f;
float Y = 0.0f;
float Z = 0.0f;
float O = 0.0f;
};
bool LoadNode(std::string const& key, NodeLocation& node) const;
bool AppendCurrent(Player* player, bool force);
float Distance(ClientlessRecordedRoutePoint const& a, ClientlessRecordedRoutePoint const& b) const;
float Distance(Player const* player, NodeLocation const& node) const;
void RecalculateLength();
ClientlessRouteRecorderState _state = ClientlessRouteRecorderState::Inactive;
ObjectGuid _recorderGuid;
std::string _fromNode;
std::string _toNode;
NodeLocation _fromLocation;
NodeLocation _toLocation;
std::vector<ClientlessRecordedRoutePoint> _points;
float _length = 0.0f;
uint32 _sampleTimer = 0;
static constexpr float RECORD_INTERVAL_YARDS = 6.0f;
static constexpr float NODE_ENDPOINT_RADIUS_YARDS = 20.0f;
static constexpr uint32 SAMPLE_INTERVAL_MS = 250;
};
#define sClientlessRouteRecorderMgr ClientlessRouteRecorderMgr::instance()
#endif
@@ -0,0 +1,798 @@
#include "ClientlessTransportMgr.h"
#include "Chat.h"
#include "ClientlessPlayerMgr.h"
#include "ClientlessRouteRecorderMgr.h"
#include "ClientlessTravelGraphMgr.h"
#include "ClientlessTravelMgr.h"
#include "DatabaseEnv.h"
#include "GameObject.h"
#include "GameTime.h"
#include "Opcodes.h"
#include "WorldPacket.h"
#include "Log.h"
#include "Map.h"
#include "MotionMaster.h"
#include "ObjectAccessor.h"
#include "ObjectMgr.h"
#include "Player.h"
#include "Transport.h"
#include "TransportMgr.h"
#include "World.h"
#include "WorldSession.h"
#include <algorithm>
#include <cmath>
#include <limits>
#include <sstream>
namespace
{
void PublishPassengerState(Player* player)
{
player->m_movementInfo.guid = player->GetGUID();
player->m_movementInfo.pos.Relocate(player->GetPosition());
player->m_movementInfo.time = GameTime::GetGameTimeMS();
WorldPacket update(SMSG_MOVE_UPDATE);
player->WriteMovementInfo(update);
player->SendMessageToSet(&update, true);
}
char const* PhaseName(ClientlessTransportPhase phase)
{
switch (phase)
{
case ClientlessTransportPhase::Waiting: return "Waiting";
case ClientlessTransportPhase::Boarding: return "Boarding";
case ClientlessTransportPhase::Riding: return "Riding";
case ClientlessTransportPhase::Leaving: return "Leaving";
case ClientlessTransportPhase::Arrived: return "Arrived";
case ClientlessTransportPhase::Failed: return "Failed";
}
return "Unknown";
}
bool Safe(std::string const& s) { return ClientlessRouteRecorderMgr::IsSafeKey(s); }
float Distance(Position const& a, Position const& b) { return a.GetExactDist(&b); }
uint32 Spawn(GameObject const* go) { return go->ToTransport() ? go->GetGUID().GetCounter() : go->GetSpawnId(); }
bool SameService(ClientlessTransportStop const& a, ClientlessTransportStop const& b)
{
return a.Entry == b.Entry && a.Spawn == b.Spawn;
}
Position WorldPoint(GameObject* object, ClientlessTransportPoint const& point)
{
Position result = point.Pos;
if (point.OnTransport)
{
float x, y, z, o;
result.GetPosition(x, y, z, o);
object->ToTransportBase()->CalculatePassengerPosition(x, y, z, &o);
result.Relocate(x, y, z, o);
}
return result;
}
GameObject* Resolve(Player* player, uint32 spawn)
{
if (!player || !player->IsInWorld())
return nullptr;
GameObject* go = player->GetMap()->GetGameObjectBySpawnId(spawn);
return go ? go : player->GetMap()->GetTransport(ObjectGuid(HighGuid::Mo_Transport, spawn));
}
bool LoadNode(std::string const& key, uint32& map, Position& pos)
{
if (!Safe(key))
return false;
QueryResult r = WorldDatabase.PQuery("SELECT w.map,w.position_x,w.position_y,w.position_z,w.orientation "
"FROM vcore_clientless_travel_node n JOIN vcore_clientless_world_point w ON w.point_key=n.world_point_key "
"WHERE n.node_key='%s' AND n.enabled=1 AND w.enabled=1", key.c_str());
if (!r)
return false;
Field* f = r->Fetch();
// vcore_clientless_world_point.map is SMALLINT, not INT. Field access
// must match its width; GetUInt32 returns zero in a debug build.
map = f[0].GetUInt16();
pos.Relocate(f[1].GetFloat(), f[2].GetFloat(), f[3].GetFloat(), f[4].GetFloat());
return true;
}
float PathLength(ClientlessTransportStop const& stop)
{
float sum = 0;
for (std::size_t i = 1; i < stop.Points.size(); ++i)
{
auto world = [&](ClientlessTransportPoint const& p)
{
Position pos = p.Pos;
if (p.OnTransport)
{
float x, y, z, o;
pos.GetPosition(x, y, z, o);
TransportBase::CalculatePassengerPosition(x, y, z, &o,
stop.Dock.GetPositionX(), stop.Dock.GetPositionY(), stop.Dock.GetPositionZ(), stop.Dock.GetOrientation());
pos.Relocate(x, y, z, o);
}
return pos;
};
sum += Distance(world(stop.Points[i - 1]), world(stop.Points[i]));
}
return sum;
}
}
ClientlessTransportMgr* ClientlessTransportMgr::instance()
{
static ClientlessTransportMgr mgr;
return &mgr;
}
bool ClientlessTransportMgr::IsTransportEdge(std::string const& type)
{
return type == "ship" || type == "zeppelin" || type == "tram" || type == "elevator";
}
bool ClientlessTransportMgr::EnsureLoaded()
{
return _loaded ? _available : Reload();
}
bool ClientlessTransportMgr::Reload()
{
_stops.clear();
_loaded = true;
_available = false;
// Keep existing CPC installations usable before the explicit migration.
if (!WorldDatabase.Query("SHOW TABLES LIKE 'vcore_clientless_transport_stop'") ||
!WorldDatabase.Query("SHOW TABLES LIKE 'vcore_clientless_transport_point'") ||
!WorldDatabase.Query("SHOW TABLES LIKE 'vcore_clientless_transport_stand'"))
return false;
_available = true;
QueryResult r = WorldDatabase.Query("SELECT node_key,transport_entry,transport_spawn,map,dock_x,dock_y,dock_z,dock_o,period_ms,stand_name FROM vcore_clientless_transport_stop");
if (r)
do
{
Field* f = r->Fetch();
ClientlessTransportStop stop;
stop.Node = f[0].GetString(); stop.Entry = f[1].GetUInt32(); stop.Spawn = f[2].GetUInt32(); stop.Map = f[3].GetUInt32();
stop.Dock.Relocate(f[4].GetFloat(), f[5].GetFloat(), f[6].GetFloat(), f[7].GetFloat());
stop.Period = f[8].GetUInt32(); stop.Stand = f[9].GetString();
if (Safe(stop.Node) && stop.Entry && stop.Spawn && stop.Period)
_stops.emplace(stop.Node, std::move(stop));
} while (r->NextRow());
r = WorldDatabase.Query("SELECT node_key,point_index,on_transport,x,y,z,o FROM vcore_clientless_transport_point ORDER BY node_key,point_index");
if (r)
do
{
Field* f = r->Fetch();
auto itr = _stops.find(f[0].GetString());
if (itr == _stops.end())
continue;
if (f[1].GetUInt32() != itr->second.Points.size())
{
itr->second.Period = 0;
continue;
}
ClientlessTransportPoint p;
p.OnTransport = f[2].GetBool();
p.Pos.Relocate(f[3].GetFloat(), f[4].GetFloat(), f[5].GetFloat(), f[6].GetFloat());
itr->second.Points.push_back(p);
} while (r->NextRow());
for (auto itr = _stops.begin(); itr != _stops.end(); )
{
auto const& stop = itr->second;
uint32 map = 0; Position node;
bool valid = stop.Period && stop.Points.size() >= 2 && stop.Points.size() <= 512 &&
!stop.Points.front().OnTransport && stop.Points.back().OnTransport &&
LoadNode(stop.Node, map, node) && map == stop.Map && Distance(node, stop.Points.front().Pos) <= 3.0f;
bool boarded = false;
for (auto const& p : stop.Points)
{
if (!Trinity::IsValidMapCoord(p.Pos.GetPositionX(), p.Pos.GetPositionY(), p.Pos.GetPositionZ(), p.Pos.GetOrientation()) ||
(boarded && !p.OnTransport) ||
(p.OnTransport && (std::fabs(p.Pos.GetPositionX()) > 100 ||
std::fabs(p.Pos.GetPositionY()) > 100 || std::fabs(p.Pos.GetPositionZ()) > 100))) valid = false;
boarded |= p.OnTransport;
}
if (!valid || !std::isfinite(PathLength(stop)) || PathLength(stop) > 60.0f)
{
TC_LOG_ERROR("server.adventurebot.navigation", "TRANSPORT_STOP_INVALID node='%s'", stop.Node.c_str());
itr = _stops.erase(itr);
}
else ++itr;
}
TC_LOG_INFO("server.adventurebot.navigation", "TRANSPORT_STOPS_LOADED count=%u", uint32(_stops.size()));
return true;
}
bool ClientlessTransportMgr::HasConnection(std::string const& from, std::string const& to)
{
EnsureLoaded();
auto a = _stops.find(from), b = _stops.find(to);
return a != _stops.end() && b != _stops.end() && SameService(a->second, b->second) &&
(a->second.Map != b->second.Map || Distance(a->second.Dock, b->second.Dock) > 3.0f) &&
Distance(a->second.Points.back().Pos, b->second.Points.back().Pos) <= 3.0f;
}
GameObject* ClientlessTransportMgr::FindObject(Player* player, ClientlessTransportStop const& stop) const
{
if (!player || !player->IsInWorld()) return nullptr;
GameObjectTemplate const* info = sObjectMgr->GetGameObjectTemplate(stop.Entry);
GameObject* object = info && info->type == GAMEOBJECT_TYPE_MO_TRANSPORT
? player->GetMap()->GetTransport(ObjectGuid(HighGuid::Mo_Transport, stop.Spawn))
: player->GetMap()->GetGameObjectBySpawnId(stop.Spawn);
return object && object->GetEntry() == stop.Entry && object->IsInWorld() &&
object->ToTransportBase() && object->isSpawned() && player->IsInPhase(object) ? object : nullptr;
}
bool ClientlessTransportMgr::AtDock(Player* player, GameObject* object, ClientlessTransportStop const& stop, uint32 requiredPause) const
{
TransportDockState state;
return object && player->GetMapId() == stop.Map && object->GetMapId() == stop.Map &&
Distance(*object, stop.Dock) <= 1.0f &&
std::fabs(std::remainder(object->GetOrientation() - stop.Dock.GetOrientation(), float(2 * M_PI))) < 0.10f &&
object->GetTransportDockState(state) && state.Stopped && state.RemainingPause >= requiredPause;
}
bool ClientlessTransportMgr::Sample(Player* player, Recording& recording, bool force)
{
if (!recording.Error.empty())
return false;
auto fail = [&](char const* message) { recording.Error = message; return false; };
if (!player->IsAlive() || player->IsFlying() || player->IsInFlight())
return fail("Record with a living character on foot, not flying or on a taxi.");
GameObject* object = FindObject(player, recording.Stop);
if (!object || player->GetMapId() != recording.Stop.Map || object->GetGUID() != recording.Object ||
!AtDock(player, object, recording.Stop, 0))
return fail("Transport left its recorded dock; cancel and record during a complete stop.");
bool aboard = player->GetTransport() == object->ToTransportBase();
if ((player->GetTransport() && !aboard) || (recording.Attached && !aboard))
return fail("Recorder changed or left the transport. Record land -> deck only.");
ClientlessTransportPoint point;
point.OnTransport = aboard;
point.Pos.Relocate(player->GetPosition());
if (aboard)
{
float x, y, z, o;
point.Pos.GetPosition(x, y, z, o);
object->ToTransportBase()->CalculatePassengerOffset(x, y, z, &o);
if (std::fabs(x) > 100 || std::fabs(y) > 100 || std::fabs(z) > 100)
return fail("Deck point is outside the supported transport bounds.");
point.Pos.Relocate(x, y, z, o);
}
if (!recording.Stop.Points.empty())
{
auto const& previous = recording.Stop.Points.back();
float delta = Distance(WorldPoint(object, previous), WorldPoint(object, point));
if (delta > 8.0f)
return fail("Position jump over 8 yards; do not teleport or fly while recording.");
if (!force && previous.OnTransport == point.OnTransport && delta < 0.75f)
return true;
}
if (recording.Stop.Points.size() >= 512)
return fail("Recording exceeds 512 points.");
recording.Stop.Points.push_back(point);
recording.Attached |= aboard;
if (PathLength(recording.Stop) > 60.0f)
return fail("Dock transition exceeds 60 yards; move the shared node closer to the ramp.");
return true;
}
bool ClientlessTransportMgr::Save(Player* player, std::string const& stand, std::string& error)
{
auto itr = _recordings.find(player->GetGUID().GetCounter());
if (itr == _recordings.end()) { error = "No active recording."; return false; }
Recording& recording = itr->second;
if (!Safe(stand) || !Sample(player, recording, true) || !recording.Attached || recording.Stop.Points.size() < 2 || player->IsFalling())
{
error = recording.Error.empty() ? "Finish aboard the recorded transport and specify a valid stand name." : recording.Error;
return false;
}
auto& stop = recording.Stop;
if (WorldDatabase.PQuery("SELECT 1 FROM vcore_clientless_transport_stop WHERE node_key='%s'", stop.Node.c_str()))
{ error = "This dock has already been saved; explicitly forget it before replacing a recording."; return false; }
uint32 map = 0; Position node;
if (!LoadNode(stop.Node, map, node) || map != stop.Map || Distance(node, stop.Points.front().Pos) > 0.25f)
{ error = "Shared node changed during recording; cancel and start again."; return false; }
auto const& pos = stop.Points.back().Pos;
QueryResult existing = WorldDatabase.PQuery("SELECT x,y,z FROM vcore_clientless_transport_stand WHERE transport_entry=%u AND name='%s'", stop.Entry, stand.c_str());
if (existing)
{
Field* f = existing->Fetch(); Position target;
target.Relocate(f[0].GetFloat(), f[1].GetFloat(), f[2].GetFloat());
if (Distance(pos, target) > 1.5f) { error = "Stand name already exists at another deck point; stand there or use another name."; return false; }
}
WorldDatabaseTransaction trans = WorldDatabase.BeginTransaction();
trans->PAppend("DELETE FROM vcore_clientless_transport_point WHERE node_key='%s'", stop.Node.c_str());
trans->PAppend("INSERT INTO vcore_clientless_transport_stop VALUES ('%s',%u,%u,%u,%.9f,%.9f,%.9f,%.9f,%u,'%s')",
stop.Node.c_str(), stop.Entry, stop.Spawn, stop.Map, stop.Dock.GetPositionX(), stop.Dock.GetPositionY(), stop.Dock.GetPositionZ(), stop.Dock.GetOrientation(), stop.Period, stand.c_str());
uint32 index = 0;
for (auto const& p : stop.Points)
trans->PAppend("INSERT INTO vcore_clientless_transport_point VALUES ('%s',%u,%u,%.9f,%.9f,%.9f,%.9f)",
stop.Node.c_str(), index++, p.OnTransport ? 1u : 0u, p.Pos.GetPositionX(), p.Pos.GetPositionY(), p.Pos.GetPositionZ(), p.Pos.GetOrientation());
trans->PAppend("INSERT IGNORE INTO vcore_clientless_transport_stand VALUES (%u,'%s',%.9f,%.9f,%.9f,%.9f)", stop.Entry, stand.c_str(),
pos.GetPositionX(), pos.GetPositionY(), pos.GetPositionZ(), pos.GetOrientation());
WorldDatabase.DirectCommitTransaction(trans);
std::string nodeKey = stop.Node;
std::size_t pointCount = stop.Points.size();
if (!Reload() || !_stops.count(nodeKey) || _stops.at(nodeKey).Points.size() != pointCount)
{ error = "Recorded stop could not be reloaded; inspect database errors. Unsaved recording retained."; return false; }
_recordings.erase(itr);
return true;
}
bool ClientlessTransportMgr::Start(Player* player, std::string const& from, std::string const& to, std::string& error)
{
if (!player || !player->IsInWorld() || player->GetTransport() || !HasConnection(from, to))
{ error = "Transport stops are not recorded/compatible, or player is already aboard."; return false; }
auto const& source = _stops.at(from);
if (source.Map != player->GetMapId() || Distance(*player, source.Points.front().Pos) > 3.0f)
{ error = "Player has not reached the shared departure node."; return false; }
Ride ride;
ride.From = source; ride.To = _stops.at(to);
ride.RequiredPause = uint32(PathLength(source) / std::max(1.0f, player->GetSpeed(MOVE_RUN)) * 1000.0f) + uint32(source.Points.size()) * 100 + 1500;
ride.Status.Phase = ClientlessTransportPhase::Waiting;
ride.Status.Detail = "waiting for recorded transport at departure dock";
sClientlessTravelMgr->CancelTravel(player->GetGUID().GetCounter());
player->StopMoving();
_rides[player->GetGUID().GetCounter()] = std::move(ride);
TC_LOG_INFO("server.adventurebot.navigation", "TRANSPORT_WAIT guid=%u from='%s' to='%s' spawn=%u boardingMs=%u",
player->GetGUID().GetCounter(), from.c_str(), to.c_str(), source.Spawn, _rides.at(player->GetGUID().GetCounter()).RequiredPause);
return true;
}
ClientlessTransportStatus const* ClientlessTransportMgr::GetStatus(uint32 guid) const
{
auto itr = _rides.find(guid);
return itr == _rides.end() ? nullptr : &itr->second.Status;
}
void ClientlessTransportMgr::SetPhase(Player* player, Ride& ride, ClientlessTransportPhase phase, char const* detail)
{
ride.Status = { phase, detail, ride.Abort }; ride.PhaseElapsed = 0; ride.Moving = false;
TC_LOG_INFO("server.adventurebot.navigation", "TRANSPORT_STATE guid=%u phase='%s' from='%s' to='%s' passenger=%u detail='%s'",
player->GetGUID().GetCounter(), PhaseName(phase), ride.From.Node.c_str(), ride.To.Node.c_str(), player->GetTransport() ? 1u : 0u, detail);
}
void ClientlessTransportMgr::Cancel(uint32 guid)
{
auto itr = _rides.find(guid);
if (itr != _rides.end()) itr->second.Abort = true;
}
void ClientlessTransportMgr::UpdateRide(Player* player, Ride& ride, uint32 diff)
{
using Phase = ClientlessTransportPhase;
if (ride.Status.Phase == Phase::Arrived || ride.Status.Phase == Phase::Failed) return;
ride.Elapsed += diff; ride.PhaseElapsed += diff;
if (player->IsBeingTeleported())
{
player->GetSession()->CompleteClientlessTeleport();
return;
}
GameObject* object = FindObject(player, ride.From);
uint32 limit = uint32(std::min<uint64>(1800000, std::max<uint64>(180000, uint64(ride.From.Period) * 3 + 30000)));
auto fail = [&](char const* detail)
{
player->StopMoving();
SetPhase(player, ride, Phase::Failed, detail);
// Never detach an actual passenger in midair or in the sea.
};
if (ride.Elapsed > limit) { fail("transport timeout; any passenger attachment retained"); return; }
if (ride.Status.Phase == Phase::Waiting)
{
if (ride.Abort) { fail("transport cancelled on land"); return; }
if (!AtDock(player, object, ride.From, ride.RequiredPause)) return;
if (Distance(*player, ride.From.Points.front().Pos) > 3.0f)
{ fail("player moved away from departure node while waiting"); return; }
ride.Point = 0;
SetPhase(player, ride, Phase::Boarding, "boarding recorded land-to-deck transition");
}
if (ride.Status.Phase == Phase::Boarding && ride.Abort)
{
player->StopMoving();
if (!player->GetTransport()) { fail("transport cancelled on land"); return; }
SetPhase(player, ride, Phase::Riding, "boarding cancelled; waiting for safe dock");
}
if (ride.Status.Phase == Phase::Riding)
{
if (!object || player->GetTransport() != object->ToTransportBase())
{ fail("transport passenger identity lost"); return; }
ClientlessTransportStop const* arrival = nullptr;
if (AtDock(player, object, ride.To, uint32(PathLength(ride.To) / std::max(1.0f, player->GetSpeed(MOVE_RUN)) * 1000) + uint32(ride.To.Points.size()) * 100 + 1500)) arrival = &ride.To;
else if (ride.Abort && AtDock(player, object, ride.From, ride.RequiredPause)) arrival = &ride.From;
if (!arrival) return;
if (arrival == &ride.From) ride.To = ride.From;
ride.Point = ride.To.Points.size();
SetPhase(player, ride, Phase::Leaving, "leaving via reversed recorded transition");
}
if (ride.Status.Phase != Phase::Boarding && ride.Status.Phase != Phase::Leaving) return;
bool boarding = ride.Status.Phase == Phase::Boarding;
auto const& stop = boarding ? ride.From : ride.To;
if (!object || !AtDock(player, object, stop, 0))
{
if (player->GetTransport())
{
player->StopMoving(); ride.Abort = true;
SetPhase(player, ride, Phase::Riding, "departure during transition; retaining passenger until safe dock");
}
else fail("transport departed during land transition");
return;
}
if (ride.Moving)
{
if (player->IsSplineEnabled())
{
if (ride.PhaseElapsed > ride.PointTimeout) fail("recorded transition spline timeout");
return;
}
auto const& previous = stop.Points[boarding ? ride.Point - 1 : ride.Point];
if (Distance(*player, WorldPoint(object, previous)) > 1.5f)
{ fail("recorded transition stopped before target point"); return; }
if (!previous.OnTransport && player->GetTransport() == object->ToTransportBase())
{
object->ToTransportBase()->RemovePassenger(player);
PublishPassengerState(player);
}
ride.Moving = false;
}
if ((boarding && ride.Point >= stop.Points.size()) || (!boarding && ride.Point == 0))
{
if (boarding)
{
if (player->GetTransport() != object->ToTransportBase()) { fail("boarding did not attach passenger"); return; }
SetPhase(player, ride, Phase::Riding, "aboard; waiting for destination dock");
}
else
{
if (player->GetTransport() || Distance(*player, stop.Points.front().Pos) > 2.0f)
{ fail("shared arrival node not reached on land"); return; }
SetPhase(player, ride, ride.Abort ? Phase::Failed : Phase::Arrived, ride.Abort ? "cancelled at safe dock" : "arrived at shared destination node");
}
return;
}
auto const& point = stop.Points[boarding ? ride.Point : ride.Point - 1];
Position target = WorldPoint(object, point);
if (Distance(*player, target) > 8.0f) { fail("recorded transition has an unsafe gap over 8 yards"); return; }
bool attached = player->GetTransport() == object->ToTransportBase();
if (point.OnTransport && !attached)
{
if (player->GetTransport()) { fail("another passenger owner already attached"); return; }
float x, y, z, o;
player->GetPosition(x, y, z, o);
object->ToTransportBase()->CalculatePassengerOffset(x, y, z, &o);
if (std::fabs(x) > 100 || std::fabs(y) > 100 || std::fabs(z) > 100)
{ fail("passenger attachment outside supported local bounds"); return; }
player->m_movementInfo.transport.Reset();
player->m_movementInfo.transport.pos.Relocate(x, y, z, o);
TransportDockState state; object->GetTransportDockState(state);
player->m_movementInfo.transport.time = state.Progress;
object->ToTransportBase()->AddPassenger(player);
if (player->GetTransport() != object->ToTransportBase())
{ fail("native passenger attachment failed"); return; }
PublishPassengerState(player);
}
// Keep ownership until the first land point is physically reached. If
// departure interrupts disembarkation, the bot remains a real passenger.
if (!point.OnTransport && attached && Distance(*player, target) <= 0.1f)
{
object->ToTransportBase()->RemovePassenger(player);
PublishPassengerState(player);
}
Position path[2]; path[0].Relocate(player->GetPosition()); path[1] = target;
if (Distance(path[0], path[1]) > 0.1f)
player->GetMotionMaster()->MoveSmoothPath(1, path, 2, false);
ride.Moving = Distance(path[0], path[1]) > 0.1f; ride.PhaseElapsed = 0;
ride.PointTimeout = 10000;
if (boarding) ++ride.Point; else --ride.Point;
}
void ClientlessTransportMgr::Update(uint32 diff)
{
for (auto itr = _recordings.begin(); itr != _recordings.end(); )
{
Player* player = ObjectAccessor::FindConnectedPlayer(ObjectGuid(HighGuid::Player, itr->first));
if (!player || !player->IsInWorld()) { itr = _recordings.erase(itr); continue; }
Recording& recording = itr->second;
recording.Timer += diff;
if (recording.Timer >= 100)
{
recording.Timer = 0;
if (recording.Error.empty() && !Sample(player, recording, false) && !recording.Error.empty())
{
TC_LOG_WARN("server.adventurebot.navigation", "TRANSPORT_RECORD_FAILED recorder=%u detail='%s'", itr->first, recording.Error.c_str());
// Keep the buffer and diagnostic; no further automatic samples.
recording.Timer = 0;
}
}
++itr;
}
for (auto itr = _rides.begin(); itr != _rides.end(); )
{
WorldSession* session = sWorld->FindClientlessSession(itr->first);
if (!session || sWorld->GetClientlessPlayerState(itr->first) != ClientlessPlayerState::Online)
{ itr = _rides.erase(itr); continue; }
Player* player = session->GetPlayer();
if (player) UpdateRide(player, itr->second, diff);
++itr;
}
}
bool ClientlessTransportMgr::Command(ChatHandler* handler, char const* args)
{
std::istringstream input(args ? args : "");
std::string command; input >> command;
Player* gm = handler->GetSession() ? handler->GetSession()->GetPlayer() : nullptr;
if (command == "reload") Reload();
if (!EnsureLoaded())
{
handler->SendSysMessage("Transport schema unavailable. Apply sql/custom/world/2026_10_25_00_clientless_transport.sql to the test world DB.");
return true;
}
auto say = [&](std::string const& text) { handler->SendSysMessage(text.c_str()); };
if (command == "services")
{
uint32 requested = 0;
input >> std::ws;
if (input.peek() != std::char_traits<char>::eof())
{
std::string extra;
if (!(input >> requested) || !requested || (input >> extra))
{ say("Usage: .clientless transport services [spawnId]"); return true; }
}
QueryResult rows = requested
? WorldDatabase.PQuery("SELECT guid,entry FROM transports WHERE guid=%u", requested)
: WorldDatabase.Query("SELECT guid,entry FROM transports ORDER BY guid");
if (!rows) { say("No configured native transport service found."); return true; }
do
{
Field* fields = rows->Fetch();
uint32 spawn = fields[0].GetUInt32(), entry = fields[1].GetUInt32();
auto info = sObjectMgr->GetGameObjectTemplate(entry);
auto route = sTransportMgr->GetTransportTemplate(entry);
if (!info || !route) continue;
uint32 stops = 0;
for (auto const& leg : route->PathLegs)
for (auto const& segment : leg.Segments)
if (segment.Delay) ++stops;
handler->PSendSysMessage("Service spawn=%u entry=%u name='%s' periodMs=%u stops=%u legs=%u instanced=%u",
spawn, entry, info->name.c_str(), route->TotalPathTime, stops, uint32(route->PathLegs.size()), route->InInstance ? 1u : 0u);
if (!requested) continue;
uint32 dock = 0;
for (auto const& leg : route->PathLegs)
for (auto const& segment : leg.Segments)
{
if (!segment.Delay) continue;
TransportMovementState state;
size_t index;
auto position = route->ComputePosition(segment.SegmentEndArrivalTimestamp, &state, &index);
if (!position || state != TransportMovementState::WaitingOnPauseWaypoint || index >= route->PathLegs.size())
continue;
handler->PSendSysMessage(" Dock %u: map=%u pose=(%.3f %.3f %.3f %.3f) arrivalMs=%u pauseMs=%u",
++dock, route->PathLegs[index].MapId, position->GetPositionX(), position->GetPositionY(),
position->GetPositionZ(), position->GetOrientation(), segment.SegmentEndArrivalTimestamp, segment.Delay);
}
if (!dock) say(" No scheduled boarding pauses. Do not create a regular passenger edge without a verified stop.");
} while (rows->NextRow());
return true;
}
if (command == "reload")
{
Reload(); sClientlessTravelGraphMgr->Reload();
handler->PSendSysMessage("Transport data reloaded: %u stops. Active rides retain their captured definitions.", uint32(_stops.size()));
return true;
}
if (command == "node")
{
std::string node; input >> node;
uint32 map = 0; Position position;
if (!LoadNode(node, map, position))
say("Transport node lookup failed: specify an existing enabled node.");
else handler->PSendSysMessage("Transport node '%s': map=%u position=(%.3f %.3f %.3f %.3f)",
node.c_str(), map, position.GetPositionX(), position.GetPositionY(), position.GetPositionZ(), position.GetOrientation());
return true;
}
if (command == "status" || command == "cancel" || command == "travel")
{
uint32 guid = 0; input >> guid;
if (!guid) { say("Usage: .clientless transport status|cancel <guid> OR travel <guid> <destinationNode>"); return true; }
if (command == "cancel")
{
auto const* status = GetStatus(guid);
if (!status || status->Phase == ClientlessTransportPhase::Arrived || status->Phase == ClientlessTransportPhase::Failed)
say("No active transport ride to cancel.");
else { Cancel(guid); say("Cancellation requested; a passenger remains attached until a safe recorded stop."); }
return true;
}
if (command == "travel")
{
std::string node; input >> node;
if (!Safe(node)) { say("Specify a valid destination node."); return true; }
ClientlessAction action; action.Name = "travel_to_node";
action.Parameters["node"] = node; action.Parameters["hard_destination"] = "1";
say(sClientlessPlayerMgr->QueueAction(guid, std::move(action)) ? "Travel action queued through the regular graph executor." : "Character is not CPC managed.");
return true;
}
auto itr = _rides.find(guid);
if (itr == _rides.end()) say("No transport ride state for this character.");
else handler->PSendSysMessage("Transport GUID %u: phase=%s from='%s' to='%s' spawn=%u point=%u elapsedMs=%u abort=%u detail='%s'",
guid, PhaseName(itr->second.Status.Phase), itr->second.From.Node.c_str(), itr->second.To.Node.c_str(), itr->second.From.Spawn,
uint32(itr->second.Point), itr->second.Elapsed, itr->second.Abort ? 1u : 0u, itr->second.Status.Detail.c_str());
return true;
}
if (command == "stops")
{
for (auto const& pair : _stops)
{
auto const& s = pair.second;
handler->PSendSysMessage("Stop '%s': entry=%u spawn=%u map=%u points=%u length=%.2f stand='%s' period=%ums dock=(%.3f %.3f %.3f)",
s.Node.c_str(), s.Entry, s.Spawn, s.Map, uint32(s.Points.size()), PathLength(s), s.Stand.c_str(), s.Period,
s.Dock.GetPositionX(), s.Dock.GetPositionY(), s.Dock.GetPositionZ());
}
handler->PSendSysMessage("Recorded transport stops: %u", uint32(_stops.size()));
return true;
}
if (command == "forget")
{
std::string node; input >> node;
if (!Safe(node)) { say("Usage: .clientless transport forget <node> (removes only the recorded dock transition)"); return true; }
WorldDatabaseTransaction trans = WorldDatabase.BeginTransaction();
trans->PAppend("DELETE FROM vcore_clientless_transport_point WHERE node_key='%s'", node.c_str());
trans->PAppend("DELETE FROM vcore_clientless_transport_stop WHERE node_key='%s'", node.c_str());
WorldDatabase.DirectCommitTransaction(trans); Reload(); sClientlessTravelGraphMgr->Reload();
say("Dock recording removed. Shared node, ground routes and deck stands retained; incomplete transport connections are not planned.");
return true;
}
if (command == "link")
{
std::string from, to, type, both; float cost = 0;
input >> from >> to >> type >> cost >> both;
if (!Safe(from) || !Safe(to) || !IsTransportEdge(type) || !std::isfinite(cost) || cost <= 0 || cost > 100000000 ||
(!both.empty() && both != "both") || !HasConnection(from, to))
{ say("Usage: .clientless transport link <fromNode> <toNode> <ship|zeppelin|tram|elevator> <graphCost> [both]. Both stops must use the same spawn and deck stand."); return true; }
auto const& a = _stops.at(from);
GameObjectTemplate const* info = sObjectMgr->GetGameObjectTemplate(a.Entry);
if (!info || ((type == "tram" || type == "elevator") ? info->type != GAMEOBJECT_TYPE_TRANSPORT : info->type != GAMEOBJECT_TYPE_MO_TRANSPORT))
{ say("Edge type does not match the recorded GameObject transport implementation."); return true; }
if (WorldDatabase.PQuery("SELECT id FROM vcore_clientless_travel_edge WHERE from_node_key='%s' AND to_node_key='%s' AND edge_type='%s'", from.c_str(), to.c_str(), type.c_str()))
{ say("This directed transport edge already exists; it was not overwritten."); return true; }
WorldDatabase.DirectPExecute("INSERT INTO vcore_clientless_travel_edge (from_node_key,to_node_key,edge_type,cost,bidirectional,enabled,comment) "
"VALUES ('%s','%s','%s',%.6f,%u,1,'CPC recorded native transport')", from.c_str(), to.c_str(), type.c_str(), cost, both == "both" ? 1u : 0u);
sClientlessTravelGraphMgr->Reload(); say("Transport edge created. The shared stop nodes are used in both directions.");
return true;
}
if (command == "stand")
{
std::string operation, name; input >> operation;
if (operation == "list")
{
uint32 entry = 0; input >> entry;
if (!entry && gm && gm->GetTransport())
if (GameObject* go = gm->GetMap()->GetGameObject(gm->GetTransport()->GetTransportGUID())) entry = go->GetEntry();
if (!entry) { say("Usage: .clientless transport stand list <transportEntry>"); return true; }
QueryResult r = WorldDatabase.PQuery("SELECT name,x,y,z,o FROM vcore_clientless_transport_stand WHERE transport_entry=%u ORDER BY name", entry);
if (r) do { Field* f = r->Fetch(); handler->PSendSysMessage("Stand '%s' entry=%u local=(%.3f %.3f %.3f %.3f)", f[0].GetString().c_str(), entry, f[1].GetFloat(), f[2].GetFloat(), f[3].GetFloat(), f[4].GetFloat()); } while (r->NextRow());
else say("No stands recorded for this entry.");
return true;
}
if (operation == "remove")
{
uint32 entry = 0; input >> entry >> name;
if (!entry || !Safe(name)) { say("Usage: .clientless transport stand remove <entry> <name>"); return true; }
if (WorldDatabase.PQuery("SELECT node_key FROM vcore_clientless_transport_stop WHERE transport_entry=%u AND stand_name='%s' LIMIT 1", entry, name.c_str()))
{ say("Stand is used by a stop. Forget/re-record those transitions before removing it."); return true; }
WorldDatabase.DirectPExecute("DELETE FROM vcore_clientless_transport_stand WHERE transport_entry=%u AND name='%s'", entry, name.c_str());
say("Unused deck stand removed."); return true;
}
input >> name;
if (operation != "add" || !Safe(name) || !gm || !gm->GetTransport() || gm->IsFlying() || gm->IsFalling())
{ say("Stand aboard the intended transport, then use .clientless transport stand add <name>."); return true; }
GameObject* go = gm->GetMap()->GetGameObject(gm->GetTransport()->GetTransportGUID());
if (!go || !go->ToTransportBase()) { say("Transport GameObject not found."); return true; }
float x, y, z, o; gm->GetPosition(x, y, z, o); go->ToTransportBase()->CalculatePassengerOffset(x, y, z, &o);
if (std::fabs(x) > 100 || std::fabs(y) > 100 || std::fabs(z) > 100) { say("Stand outside supported deck bounds."); return true; }
if (WorldDatabase.PQuery("SELECT 1 FROM vcore_clientless_transport_stand WHERE transport_entry=%u AND name='%s'", go->GetEntry(), name.c_str()))
{ say("Stand name already recorded; use a new name to preserve the existing point."); return true; }
WorldDatabase.DirectPExecute("INSERT INTO vcore_clientless_transport_stand VALUES (%u,'%s',%.9f,%.9f,%.9f,%.9f)", go->GetEntry(), name.c_str(), x, y, z, o);
handler->PSendSysMessage("Stand '%s' saved for entry=%u at local (%.3f %.3f %.3f %.3f).", name.c_str(), go->GetEntry(), x, y, z, o);
return true;
}
if (command == "inspect" || command == "near")
{
uint32 spawn = 0, guid = 0;
float radius = 50;
if (command == "inspect") input >> spawn >> guid;
else input >> radius >> guid;
Player* observer = gm;
if (!observer && guid)
if (WorldSession* session = sWorld->FindClientlessSession(guid)) observer = session->GetPlayer();
if (!observer || !observer->IsInWorld()) { say("In console use: transport inspect <spawnId> <botGuid> OR transport near <radius> <botGuid>."); return true; }
auto print = [&](GameObject* go)
{
if (!go || !go->IsTransport() || !go->IsInWorld() || !observer->IsInPhase(go)) return;
TransportDockState state; go->GetTransportDockState(state);
handler->PSendSysMessage("Transport '%s': entry=%u spawn=%u guid='%s' map=%u pos=(%.3f %.3f %.3f %.3f) distance=%.2f stopped=%u remainingPause=%u period=%u progress=%u",
go->GetName().c_str(), go->GetEntry(), Spawn(go), go->GetGUID().ToString().c_str(), go->GetMapId(), go->GetPositionX(), go->GetPositionY(), go->GetPositionZ(), go->GetOrientation(),
observer->GetExactDist(go), state.Stopped ? 1u : 0u, state.RemainingPause, state.Period, state.Progress);
};
if (command == "inspect")
{
GameObject* go = Resolve(observer, spawn);
if (go) print(go); else say("Spawn is not loaded on the observer's current map.");
}
else
{
radius = std::isfinite(radius) ? std::max(1.0f, std::min(200.0f, radius)) : 50;
for (auto const& pair : observer->GetMap()->GetGameObjectBySpawnIdStore())
if (observer->GetExactDist(pair.second) <= radius) print(pair.second);
QueryResult r = WorldDatabase.Query("SELECT guid FROM transports");
if (r) do { GameObject* go = observer->GetMap()->GetTransport(ObjectGuid(HighGuid::Mo_Transport, r->Fetch()[0].GetUInt32())); if (go && observer->GetExactDist(go) <= radius) print(go); } while (r->NextRow());
}
return true;
}
if (command == "record")
{
if (!gm || !gm->IsInWorld()) { say("Dock recording needs an in-game GM player."); return true; }
uint32 guid = gm->GetGUID().GetCounter();
std::string op; input >> op;
if (op == "cancel") { _recordings.erase(guid); say("Your unsaved recording was cancelled."); return true; }
if (op == "info")
{
auto itr = _recordings.find(guid);
if (itr == _recordings.end()) say("No active recording.");
else handler->PSendSysMessage("Recording node='%s' spawn=%u points=%u length=%.2f aboard=%u error='%s'", itr->second.Stop.Node.c_str(), itr->second.Stop.Spawn,
uint32(itr->second.Stop.Points.size()), PathLength(itr->second.Stop), itr->second.Attached ? 1u : 0u, itr->second.Error.c_str());
return true;
}
if (op == "start")
{
std::string node; uint32 spawn = 0; input >> node >> spawn;
if (_recordings.count(guid)) { say("Finish or cancel your existing recording first."); return true; }
Position position; uint32 map = 0;
if (!Safe(node) || !spawn)
{ say("Usage: .clientless transport record start <sharedNode> <spawnId>"); return true; }
if (!LoadNode(node, map, position))
{ handler->PSendSysMessage("Recording not started: node '%s' does not exist or is disabled.", node.c_str()); return true; }
if (map != gm->GetMapId())
{ handler->PSendSysMessage("Recording not started: node map=%u, recorder map=%u.", map, gm->GetMapId()); return true; }
float const nodeDistance = Distance(*gm, position);
if (nodeDistance > 2.0f)
{ handler->PSendSysMessage("Recording not started: distance to node '%s' is %.3f yd (maximum 2.000); node=(%.3f %.3f %.3f), recorder=(%.3f %.3f %.3f).",
node.c_str(), nodeDistance, position.GetPositionX(), position.GetPositionY(), position.GetPositionZ(), gm->GetPositionX(), gm->GetPositionY(), gm->GetPositionZ()); return true; }
if (gm->GetTransport())
{ handler->PSendSysMessage("Recording not started: recorder is already a passenger of '%s'. Start on the platform, before boarding.",
gm->GetTransport()->GetTransportGUID().ToString().c_str()); return true; }
GameObject* go = Resolve(gm, spawn);
if (!go)
{ handler->PSendSysMessage("Recording not started: spawn %u is not loaded on map %u.", spawn, gm->GetMapId()); return true; }
if (!go->ToTransportBase())
{ handler->PSendSysMessage("Recording not started: spawn %u entry %u is not a supported transport.", spawn, go->GetEntry()); return true; }
if (!gm->IsInPhase(go))
{ say("Recording not started: recorder and transport are in different phases."); return true; }
float const transportDistance = gm->GetExactDist(go);
if (transportDistance > 100.0f)
{ handler->PSendSysMessage("Recording not started: spawn %u is %.3f yd away (maximum 100.000); transport=(%.3f %.3f %.3f).",
spawn, transportDistance, go->GetPositionX(), go->GetPositionY(), go->GetPositionZ()); return true; }
TransportDockState state;
if (!go->GetTransportDockState(state))
{ handler->PSendSysMessage("Recording not started: no server dock state available for spawn %u entry %u.", spawn, go->GetEntry()); return true; }
if (!state.Stopped)
{ handler->PSendSysMessage("Recording not started: spawn %u is moving according to its animation; progress=%u/%u ms, remainingPause=%u ms, nodeDistance=%.3f yd, transportDistance=%.3f yd.",
spawn, state.Progress, state.Period, state.RemainingPause, nodeDistance, transportDistance); return true; }
if (WorldDatabase.PQuery("SELECT 1 FROM vcore_clientless_transport_stop WHERE node_key='%s'", node.c_str())) { say("Dock already recorded; use transport forget <node> explicitly before replacing it."); return true; }
Recording recording;
recording.Stop.Node = node; recording.Stop.Entry = go->GetEntry(); recording.Stop.Spawn = Spawn(go);
recording.Stop.Map = map; recording.Stop.Period = state.Period; recording.Stop.Dock.Relocate(go->GetPosition());
recording.Object = go->GetGUID(); recording.NodePosition = position;
recording.Stop.Points.push_back({ false, position });
_recordings[guid] = recording;
say("Recording started. Walk normally onto this transport and to a deck stand during this stop. Then: transport record stop <standName>.");
return true;
}
auto itr = _recordings.find(guid);
if (itr == _recordings.end()) { say("No active recording."); return true; }
if (op == "mark") { bool ok = Sample(gm, itr->second, true); say(ok ? "Transition point marked." : itr->second.Error); return true; }
if (op == "stop")
{
std::string stand, error; input >> stand;
if (Save(gm, stand, error)) { sClientlessTravelGraphMgr->Reload(); say("Dock transition and local deck stand saved. Record the other stop on the same physical transport, ending at the same stand."); }
else say(error);
return true;
}
}
say("Transport tools: services [spawn], node <key>, near [radius], inspect <spawn> [botGuid], stops, stand add|list, record start|mark|info|stop|cancel, link <from> <to> <type> <cost> [both], forget <node>, reload, status|cancel <guid>, travel <guid> <node>.");
return true;
}
@@ -0,0 +1,97 @@
/* Vcore: recorded dock transitions and native clientless transport travel. */
#ifndef VCORE_CLIENTLESS_TRANSPORT_MGR_H
#define VCORE_CLIENTLESS_TRANSPORT_MGR_H
#include "Common.h"
#include "ObjectGuid.h"
#include "Position.h"
#include <string>
#include <unordered_map>
#include <vector>
class Player;
class GameObject;
class ChatHandler;
struct ClientlessTransportPoint
{
bool OnTransport = false;
Position Pos;
};
struct ClientlessTransportStop
{
std::string Node;
uint32 Entry = 0;
uint32 Spawn = 0;
uint32 Map = 0;
uint32 Period = 0;
Position Dock;
std::string Stand;
std::vector<ClientlessTransportPoint> Points;
};
enum class ClientlessTransportPhase : uint8
{
Waiting, Boarding, Riding, Leaving, Arrived, Failed
};
struct ClientlessTransportStatus
{
ClientlessTransportPhase Phase = ClientlessTransportPhase::Failed;
std::string Detail;
bool Cancelled = false;
};
class TC_GAME_API ClientlessTransportMgr
{
public:
static ClientlessTransportMgr* instance();
bool Reload();
bool HasConnection(std::string const& from, std::string const& to);
bool Start(Player* player, std::string const& from, std::string const& to, std::string& error);
ClientlessTransportStatus const* GetStatus(uint32 guid) const;
void Cancel(uint32 guid);
void Update(uint32 diff);
bool Command(ChatHandler* handler, char const* args);
static bool IsTransportEdge(std::string const& type);
private:
struct Recording
{
ClientlessTransportStop Stop;
ObjectGuid Object;
Position NodePosition;
bool Attached = false;
std::string Error;
uint32 Timer = 0;
};
struct Ride
{
ClientlessTransportStatus Status;
ClientlessTransportStop From, To;
std::size_t Point = 0;
uint32 Elapsed = 0;
uint32 PhaseElapsed = 0;
uint32 RequiredPause = 0;
uint32 PointTimeout = 0;
bool Moving = false;
bool Abort = false;
};
bool EnsureLoaded();
bool Sample(Player* player, Recording& recording, bool force);
bool Save(Player* player, std::string const& stand, std::string& error);
void UpdateRide(Player* player, Ride& ride, uint32 diff);
void SetPhase(Player* player, Ride& ride, ClientlessTransportPhase phase, char const* detail);
GameObject* FindObject(Player* player, ClientlessTransportStop const& stop) const;
bool AtDock(Player* player, GameObject* object, ClientlessTransportStop const& stop, uint32 requiredPause) const;
bool _loaded = false;
bool _available = false;
std::unordered_map<std::string, ClientlessTransportStop> _stops;
std::unordered_map<uint32, Recording> _recordings;
std::unordered_map<uint32, Ride> _rides;
};
#define sClientlessTransportMgr ClientlessTransportMgr::instance()
#endif
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,182 @@
/*
* Vcore AdventureBot travel graph.
*
* 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.
*
* 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
#include "Common.h"
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
class Player;
// Canonical Vcore travel-node types:
// ground, taxi, ship, zeppelin, tram, portal, elevator
//
// Node type describes what the place is. Edge type describes how travel occurs.
// Ground edges may connect any node type; specialized edge executors may impose
// stricter endpoint rules.
struct TC_GAME_API ClientlessTravelNode
{
uint32 DatabaseId = 0;
std::string Key;
std::string Type;
std::string WorldPointKey;
uint32 MapId = 0;
float X = 0.0f;
float Y = 0.0f;
float Z = 0.0f;
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;
std::string FromNode;
std::string ToNode;
std::string Type;
float Cost = 1.0f;
bool Reverse = false;
};
struct TC_GAME_API ClientlessTravelEdgePoint
{
uint32 MapId = 0;
float X = 0.0f;
float Y = 0.0f;
float Z = 0.0f;
float O = 0.0f;
};
// VCORE_C820_VIRTUAL_EXIT_NODES: a location target used by the adaptive
// graph search. Node and road exits compete by complete travel cost.
struct TC_GAME_API ClientlessTravelLocationGoal
{
uint32 MapId = 0;
float X = 0.0f;
float Y = 0.0f;
float Z = 0.0f;
float AnchorRadius = 1000.0f;
};
struct TC_GAME_API ClientlessTravelRouteStep
{
std::string NodeKey;
std::string NodeType;
std::string WorldPointKey;
std::string EdgeType;
uint32 EdgeDatabaseId = 0;
bool EdgeReverse = false;
// VCORE_VIRTUAL_GROUND_EDGE_ENTRY_V1: index in execution-direction edge points.
// -1 means replay the entire recorded ground edge as before.
int32 GroundStartIndex = -1;
// C8.20: inclusive index in the ORIGINAL execution-direction road points.
// -1 keeps the stored edge endpoint. Applied before GroundStartIndex.
int32 GroundEndIndex = -1;
uint32 MapId = 0;
float X = 0.0f;
float Y = 0.0f;
float Z = 0.0f;
float O = 0.0f;
};
class TC_GAME_API ClientlessTravelGraphMgr
{
public:
static ClientlessTravelGraphMgr* instance();
bool Reload();
bool EnsureLoaded();
bool PlanRoute(
Player const* player,
std::string const& destinationNode,
float startRadius,
std::vector<ClientlessTravelRouteStep>& route,
std::string& error,
std::unordered_set<uint32> const* blockedEdgeIds = nullptr);
bool PlanRouteToPosition(
Player const* player,
uint32 destinationMapId,
float destinationX,
float destinationY,
float destinationZ,
float startRadius,
float anchorRadius,
std::vector<ClientlessTravelRouteStep>& route,
std::string& anchorNode,
std::string& error,
std::unordered_set<uint32> const* blockedEdgeIds = nullptr);
// C8.13b: transient local bridges connect nearby graph islands for one
// route plan. Bridges are never persisted and may never follow another
// bridge directly; at least one real TravelGraph edge must lie between
// two local bridges. If the destination remains unreachable, a useful
// partial route is returned when possible.
bool PlanAdaptiveRoute(
Player const* player,
std::string const& destinationNode,
float startRadius,
float bridgeRadius,
std::vector<ClientlessTravelRouteStep>& route,
bool& complete,
std::string& reachedNode,
std::string& error,
std::unordered_set<uint32> const* blockedEdgeIds = nullptr,
std::unordered_set<std::string> const* blockedBridgeKeys = nullptr,
ClientlessTravelLocationGoal const* locationGoal = nullptr);
bool FindNearestNodeToPosition(
uint32 mapId,
float x,
float y,
float z,
std::string& nodeKey,
float& distance) const;
uint32 GetNodeCount() const { return uint32(_nodes.size()); }
uint32 GetEdgeCount() const { return _edgeCount; }
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;
bool _loaded = false;
uint32 _edgeCount = 0;
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()
#endif
@@ -0,0 +1,647 @@
#include "ClientlessTravelMgr.h"
#include "ClientlessLuaEngine.h"
#include "Log.h"
#include "MotionMaster.h"
#include "PathGenerator.h"
#include "Player.h"
#include "World.h"
#include "WorldSession.h"
#include <algorithm>
#include <cmath>
#include <limits>
namespace
{
constexpr float ARRIVAL_DISTANCE = 0.5f;
constexpr float MIN_PROGRESS_DISTANCE = 0.5f;
constexpr uint32 PROGRESS_CHECK_INTERVAL_MS = 1000;
constexpr uint32 REPATH_AFTER_NO_PROGRESS_MS = 5000;
constexpr uint32 MAX_REPATHS = 5;
constexpr float MAX_LOCAL_SPLINE_LENGTH = 45.0f;
constexpr float MAX_PATH_FINAL_ERROR = 3.0f;
constexpr float MAX_EXECUTION_DEVIATION = 8.0f;
constexpr float MAX_SEGMENT_END_ERROR = 2.5f;
float Distance3D(float ax, float ay, float az, float bx, float by, float bz)
{
float const dx = bx - ax;
float const dy = by - ay;
float const dz = bz - az;
return std::sqrt(dx * dx + dy * dy + dz * dz);
}
float DistancePointToSegment(float px, float py, float pz, Position const& a, Position const& b)
{
float const abx = b.GetPositionX() - a.GetPositionX();
float const aby = b.GetPositionY() - a.GetPositionY();
float const abz = b.GetPositionZ() - a.GetPositionZ();
float const apx = px - a.GetPositionX();
float const apy = py - a.GetPositionY();
float const apz = pz - a.GetPositionZ();
float const len2 = abx * abx + aby * aby + abz * abz;
if (len2 <= 0.0001f)
return Distance3D(px, py, pz, a.GetPositionX(), a.GetPositionY(), a.GetPositionZ());
float t = (apx * abx + apy * aby + apz * abz) / len2;
t = std::max(0.0f, std::min(1.0f, t));
float const qx = a.GetPositionX() + abx * t;
float const qy = a.GetPositionY() + aby * t;
float const qz = a.GetPositionZ() + abz * t;
return Distance3D(px, py, pz, qx, qy, qz);
}
}
ClientlessTravelMgr* ClientlessTravelMgr::instance()
{
static ClientlessTravelMgr instance;
return &instance;
}
char const* ClientlessTravelMgr::StateName(ClientlessTravelState state)
{
switch (state)
{
case ClientlessTravelState::Idle: return "Idle";
case ClientlessTravelState::Moving: return "Moving";
case ClientlessTravelState::Arrived: return "Arrived";
case ClientlessTravelState::Stuck: return "Stuck";
case ClientlessTravelState::InvalidMap: return "InvalidMap";
case ClientlessTravelState::PathRejected: return "PathRejected";
}
return "Unknown";
}
bool ClientlessTravelMgr::AnalyzePath(
Player* player,
Position const& target,
ClientlessPathDiagnostics& diagnostics) const
{
diagnostics = {};
if (!player || !player->IsInWorld())
{
diagnostics.Reason = "player-not-in-world";
return false;
}
float const sx = player->GetPositionX();
float const sy = player->GetPositionY();
float const sz = player->GetPositionZ();
float const tx = target.GetPositionX();
float const ty = target.GetPositionY();
float const tz = target.GetPositionZ();
diagnostics.DirectDistance = Distance3D(sx, sy, sz, tx, ty, tz);
PathGenerator path(player);
diagnostics.Calculated = path.CalculatePath(tx, ty, tz);
diagnostics.PathType = uint32(path.GetPathType());
auto const& points = path.GetPath();
diagnostics.PointCount = uint32(points.size());
if (!points.empty())
{
for (std::size_t i = 1; i < points.size(); ++i)
{
float const segment = Distance3D(
points[i - 1].x, points[i - 1].y, points[i - 1].z,
points[i].x, points[i].y, points[i].z);
diagnostics.PathLength += segment;
diagnostics.MaxSegmentLength = std::max(diagnostics.MaxSegmentLength, segment);
diagnostics.MaxZDelta = std::max(diagnostics.MaxZDelta, std::fabs(points[i].z - points[i - 1].z));
}
auto const& last = points.back();
diagnostics.FinalDistance = Distance3D(last.x, last.y, last.z, tx, ty, tz);
}
else
diagnostics.FinalDistance = diagnostics.DirectDistance;
uint32 const pathType = diagnostics.PathType;
// VCORE_C814A_NAVIGATION_FIX: narrowly accept a short incomplete MMAP path
// only when its actual points reach the target without unsafe segments.
bool const shortEndReached =
diagnostics.Calculated &&
(pathType & uint32(PATHFIND_INCOMPLETE)) != 0 &&
(pathType & uint32(PATHFIND_NOPATH)) == 0 &&
diagnostics.PointCount >= 2 &&
diagnostics.DirectDistance <= 25.0f &&
diagnostics.PathLength <= 40.0f &&
diagnostics.FinalDistance <= 0.25f &&
diagnostics.MaxSegmentLength <= 6.0f &&
diagnostics.MaxZDelta <= 4.0f;
diagnostics.Complete =
diagnostics.Calculated &&
(pathType & uint32(PATHFIND_NOPATH)) == 0 &&
((pathType & uint32(PATHFIND_INCOMPLETE)) == 0 || shortEndReached);
auto addReason = [&](char const* reason)
{
if (!diagnostics.Reason.empty())
diagnostics.Reason += ",";
diagnostics.Reason += reason;
diagnostics.Suspicious = true;
};
if (!diagnostics.Calculated)
addReason("calculate-failed");
if (pathType & uint32(PATHFIND_NOPATH))
addReason("no-path");
if (pathType & uint32(PATHFIND_INCOMPLETE))
addReason("incomplete");
if (diagnostics.PointCount == 0 && diagnostics.DirectDistance > ARRIVAL_DISTANCE)
addReason("empty-path");
if (diagnostics.MaxSegmentLength > 25.0f)
addReason("large-segment");
if (diagnostics.MaxZDelta > 8.0f)
addReason("large-z-delta");
if (diagnostics.FinalDistance > MAX_PATH_FINAL_ERROR)
addReason("far-from-target");
if (diagnostics.DirectDistance > 5.0f && diagnostics.PathLength > diagnostics.DirectDistance * 8.0f)
addReason("extreme-detour");
if (diagnostics.Reason.empty())
diagnostics.Reason = "ok";
return diagnostics.Calculated;
}
float ClientlessTravelMgr::DistanceFromActiveSpline(Player const* player, TravelRecord const& record) const
{
if (!player || record.ActiveSpline.empty())
return 0.0f;
if (record.ActiveSpline.size() == 1)
{
Position const& p = record.ActiveSpline.front();
return Distance3D(
player->GetPositionX(), player->GetPositionY(), player->GetPositionZ(),
p.GetPositionX(), p.GetPositionY(), p.GetPositionZ());
}
float best = std::numeric_limits<float>::max();
for (std::size_t i = 1; i < record.ActiveSpline.size(); ++i)
best = std::min(best, DistancePointToSegment(
player->GetPositionX(), player->GetPositionY(), player->GetPositionZ(),
record.ActiveSpline[i - 1], record.ActiveSpline[i]));
return best;
}
bool ClientlessTravelMgr::LaunchPath(Player* player, TravelRecord& record)
{
if (!player || !player->IsInWorld())
return false;
player->AttackStop();
player->CastStop();
player->StopMoving();
player->GetMotionMaster()->Clear();
record.ActiveSpline.clear();
Position const& target = record.Status.Target;
// VCORE_C814A_NAVIGATION_FIX: an already reached node is not a failed spline.
if (Distance3D(player->GetPositionX(), player->GetPositionY(), player->GetPositionZ(),
target.GetPositionX(), target.GetPositionY(), target.GetPositionZ()) <= ARRIVAL_DISTANCE)
{
record.Status.State = ClientlessTravelState::Arrived;
record.Status.RemainingDistance = 0.0f;
record.Status.NoProgressMs = 0;
TC_LOG_INFO("server.adventurebot.navigation",
"NAV_ALREADY_ARRIVED guid=%u name='%s' map=%u",
player->GetGUID().GetCounter(), player->GetName().c_str(), player->GetMapId());
return true;
}
AnalyzePath(player, target, record.Status.Path);
ClientlessPathDiagnostics const& nav = record.Status.Path;
record.Status.NavSequence = _nextNavSequence++;
TC_LOG_INFO("server.adventurebot.navigation",
"NAV_PLAN seq=%llu guid=%u name='%s' map=%u segment=%u "
"start=(%.3f %.3f %.3f) target=(%.3f %.3f %.3f) "
"type=0x%X calculated=%u complete=%u suspicious=%u points=%u "
"direct=%.2f path=%.2f maxSegment=%.2f maxZDelta=%.2f finalDistance=%.2f "
"repaths=%u reason='%s'",
static_cast<unsigned long long>(record.Status.NavSequence),
player->GetGUID().GetCounter(), player->GetName().c_str(), player->GetMapId(),
record.Status.SegmentIndex,
player->GetPositionX(), player->GetPositionY(), player->GetPositionZ(),
target.GetPositionX(), target.GetPositionY(), target.GetPositionZ(),
nav.PathType, nav.Calculated ? 1u : 0u, nav.Complete ? 1u : 0u,
nav.Suspicious ? 1u : 0u, nav.PointCount,
nav.DirectDistance, nav.PathLength, nav.MaxSegmentLength,
nav.MaxZDelta, nav.FinalDistance, record.Status.RepathCount,
nav.Reason.c_str());
// VCORE_VIRTUAL_EDGE_V11_PORTAL_APPROACH: execute only a validated partial
// MMAP corridor. Never turn this exception on for normal ground navigation.
bool const portalPartial = record.PortalApproach && nav.Calculated &&
(nav.PathType & uint32(PATHFIND_INCOMPLETE)) != 0 &&
(nav.PathType & uint32(PATHFIND_NOPATH)) == 0 &&
nav.PointCount >= 2 && nav.FinalDistance <= 6.0f &&
nav.PathLength <= 100.0f && nav.MaxSegmentLength <= 6.0f &&
nav.MaxZDelta <= 4.0f;
if (!nav.Calculated || (!nav.Complete && !portalPartial) || nav.PointCount < 2 ||
(nav.FinalDistance > MAX_PATH_FINAL_ERROR && !portalPartial))
{
record.Status.State = ClientlessTravelState::PathRejected;
record.Status.NoProgressMs = 0;
TC_LOG_WARN("server.adventurebot.navigation",
"NAV_REJECT seq=%llu guid=%u name='%s' map=%u target=(%.3f %.3f %.3f) "
"type=0x%X points=%u finalDistance=%.2f reason='%s'",
static_cast<unsigned long long>(record.Status.NavSequence),
player->GetGUID().GetCounter(), player->GetName().c_str(), player->GetMapId(),
target.GetPositionX(), target.GetPositionY(), target.GetPositionZ(),
nav.PathType, nav.PointCount, nav.FinalDistance, nav.Reason.c_str());
return false;
}
PathGenerator path(player);
if (!path.CalculatePath(target.GetPositionX(), target.GetPositionY(), target.GetPositionZ()))
{
record.Status.State = ClientlessTravelState::PathRejected;
TC_LOG_WARN("server.adventurebot.navigation",
"NAV_REJECT seq=%llu guid=%u name='%s' reason='execution-path-calc-failed'",
static_cast<unsigned long long>(record.Status.NavSequence),
player->GetGUID().GetCounter(), player->GetName().c_str());
return false;
}
auto const& points = path.GetPath();
uint32 const executionType = uint32(path.GetPathType());
// VCORE_C814A_NAVIGATION_FIX: confirm the execution path independently.
float executionLength = 0.0f;
float executionMaxSegment = 0.0f;
float executionMaxZ = 0.0f;
for (std::size_t i = 1; i < points.size(); ++i)
{
float const segment = Distance3D(points[i-1].x, points[i-1].y, points[i-1].z,
points[i].x, points[i].y, points[i].z);
executionLength += segment;
executionMaxSegment = std::max(executionMaxSegment, segment);
executionMaxZ = std::max(executionMaxZ, std::fabs(points[i].z - points[i-1].z));
}
float const executionFinalDistance = points.empty() ? nav.DirectDistance :
Distance3D(points.back().x, points.back().y, points.back().z,
target.GetPositionX(), target.GetPositionY(), target.GetPositionZ());
bool const executionShortEndReached =
(executionType & uint32(PATHFIND_INCOMPLETE)) != 0 &&
(executionType & uint32(PATHFIND_NOPATH)) == 0 &&
points.size() >= 2 &&
nav.DirectDistance <= 25.0f &&
executionLength <= 40.0f &&
executionFinalDistance <= 0.25f &&
executionMaxSegment <= 6.0f &&
executionMaxZ <= 4.0f;
if (executionShortEndReached)
TC_LOG_INFO("server.adventurebot.navigation",
"NAV_SHORT_INCOMPLETE_ACCEPT guid=%u name='%s' path=%.2f final=%.2f",
player->GetGUID().GetCounter(), player->GetName().c_str(),
executionLength, executionFinalDistance);
bool const portalExecutionPartial = record.PortalApproach && portalPartial &&
(executionType & uint32(PATHFIND_INCOMPLETE)) != 0 &&
(executionType & uint32(PATHFIND_NOPATH)) == 0 &&
points.size() >= 2 && executionFinalDistance <= 6.0f &&
executionLength <= 100.0f && executionMaxSegment <= 6.0f && executionMaxZ <= 4.0f;
if (points.size() < 2 ||
(executionType & uint32(PATHFIND_NOPATH)) != 0 ||
((executionType & uint32(PATHFIND_INCOMPLETE)) != 0 &&
!executionShortEndReached && !portalExecutionPartial))
{
record.Status.State = ClientlessTravelState::PathRejected;
TC_LOG_WARN("server.adventurebot.navigation",
"NAV_REJECT seq=%llu guid=%u name='%s' reason='execution-path-not-complete' type=0x%X points=%u",
static_cast<unsigned long long>(record.Status.NavSequence),
player->GetGUID().GetCounter(), player->GetName().c_str(),
executionType, uint32(points.size()));
return false;
}
if (portalExecutionPartial)
{
// Mark local traversal arrived at the actual MMAP endpoint only;
// the portal executor still checks IsInAreaTriggerRadius and actual map transition.
Position endpoint;
endpoint.Relocate(points.back().x, points.back().y, points.back().z, player->GetOrientation());
record.Status.Target = endpoint;
TC_LOG_INFO("server.adventurebot.navigation",
"NAV_PORTAL_PARTIAL_APPROACH guid=%u remainingToTrigger=%.2f path=%.2f",
player->GetGUID().GetCounter(), executionFinalDistance, executionLength);
}
std::vector<Position> spline;
spline.reserve(points.size() + 1);
Position current;
current.Relocate(
player->GetPositionX(), player->GetPositionY(),
player->GetPositionZ(), player->GetOrientation());
spline.push_back(current);
float lastX = current.GetPositionX();
float lastY = current.GetPositionY();
float lastZ = current.GetPositionZ();
float accumulated = 0.0f;
for (std::size_t i = 1; i < points.size(); ++i)
{
float const px = points[i].x;
float const py = points[i].y;
float const pz = points[i].z;
float const segment = Distance3D(lastX, lastY, lastZ, px, py, pz);
if (segment <= 0.05f)
continue;
if (accumulated + segment > MAX_LOCAL_SPLINE_LENGTH)
{
float const remaining = MAX_LOCAL_SPLINE_LENGTH - accumulated;
float const t = std::max(0.0f, std::min(1.0f, remaining / segment));
Position partial;
partial.Relocate(
lastX + (px - lastX) * t,
lastY + (py - lastY) * t,
lastZ + (pz - lastZ) * t,
player->GetOrientation());
spline.push_back(partial);
accumulated = MAX_LOCAL_SPLINE_LENGTH;
break;
}
Position p;
p.Relocate(px, py, pz, player->GetOrientation());
spline.push_back(p);
accumulated += segment;
lastX = px;
lastY = py;
lastZ = pz;
if (accumulated >= MAX_LOCAL_SPLINE_LENGTH)
break;
}
if (spline.size() < 2)
{
record.Status.State = ClientlessTravelState::PathRejected;
TC_LOG_WARN("server.adventurebot.navigation",
"NAV_REJECT seq=%llu guid=%u name='%s' reason='no-usable-spline-points'",
static_cast<unsigned long long>(record.Status.NavSequence),
player->GetGUID().GetCounter(), player->GetName().c_str());
return false;
}
record.ActiveSpline = spline;
++record.Status.SegmentIndex;
if (record.AllowGroundMount)
sClientlessLuaEngine->PrepareGroundMount(player, nav.DirectDistance, "local-ground");
else
sClientlessLuaEngine->DismountGroundMount(player, "local-ground-disabled");
player->GetMotionMaster()->MoveSmoothPath(
1, record.ActiveSpline.data(), record.ActiveSpline.size(), false);
Position const& segmentEnd = record.ActiveSpline.back();
TC_LOG_INFO("server.adventurebot.navigation",
"NAV_EXEC seq=%llu guid=%u name='%s' segment=%u splinePoints=%u "
"splineLength=%.2f segmentEnd=(%.3f %.3f %.3f) target=(%.3f %.3f %.3f)",
static_cast<unsigned long long>(record.Status.NavSequence),
player->GetGUID().GetCounter(), player->GetName().c_str(),
record.Status.SegmentIndex, uint32(record.ActiveSpline.size()), accumulated,
segmentEnd.GetPositionX(), segmentEnd.GetPositionY(), segmentEnd.GetPositionZ(),
target.GetPositionX(), target.GetPositionY(), target.GetPositionZ());
record.Status.State = ClientlessTravelState::Moving;
record.LastDistance = player->GetDistance(
target.GetPositionX(), target.GetPositionY(), target.GetPositionZ());
record.Status.RemainingDistance = record.LastDistance;
record.Status.NoProgressMs = 0;
record.LastObservedPosition.Relocate(player->GetPosition());
record.HasLastObservedPosition = true;
record.CheckTimerMs = PROGRESS_CHECK_INTERVAL_MS;
return true;
}
bool ClientlessTravelMgr::StartTravel(Player* player, Position const& target, bool allowGroundMount, bool portalApproach)
{
if (!player || !player->IsInWorld())
return false;
uint32 const characterGuid = player->GetGUID().GetCounter();
TravelRecord& record = _records[characterGuid];
record = {};
record.Status.State = ClientlessTravelState::Moving;
record.Status.MapId = player->GetMapId();
record.Status.Target = target;
record.AllowGroundMount = allowGroundMount;
record.PortalApproach = portalApproach;
LaunchPath(player, record);
TC_LOG_INFO("server.adventurebot.navigation",
"NAV_START guid=%u name='%s' map=%u target=(%.3f %.3f %.3f) remaining=%.2f state=%s",
characterGuid, player->GetName().c_str(), record.Status.MapId,
target.GetPositionX(), target.GetPositionY(), target.GetPositionZ(),
record.Status.RemainingDistance, StateName(record.Status.State));
return true;
}
void ClientlessTravelMgr::CancelTravel(uint32 characterGuid)
{
_records.erase(characterGuid);
}
ClientlessTravelStatus const* ClientlessTravelMgr::GetStatus(uint32 characterGuid) const
{
auto itr = _records.find(characterGuid);
return itr != _records.end() ? &itr->second.Status : nullptr;
}
void ClientlessTravelMgr::Update(uint32 diff)
{
for (auto& [characterGuid, record] : _records)
{
if (record.Status.State != ClientlessTravelState::Moving)
continue;
if (record.CheckTimerMs > diff)
{
record.CheckTimerMs -= diff;
continue;
}
uint32 const elapsed = record.CheckTimerMs + diff;
record.CheckTimerMs = PROGRESS_CHECK_INTERVAL_MS;
WorldSession* session = sWorld->FindClientlessSession(characterGuid);
Player* player = session ? session->GetPlayer() : nullptr;
if (!player || !player->IsInWorld())
continue;
if (player->GetMapId() != record.Status.MapId)
{
record.Status.State = ClientlessTravelState::InvalidMap;
TC_LOG_WARN("server.adventurebot.navigation",
"NAV_INVALID_MAP guid=%u name='%s' expected=%u actual=%u",
characterGuid, player->GetName().c_str(),
record.Status.MapId, player->GetMapId());
continue;
}
if (record.HasLastObservedPosition)
{
float const observedJump = player->GetExactDist(
record.LastObservedPosition.GetPositionX(),
record.LastObservedPosition.GetPositionY(),
record.LastObservedPosition.GetPositionZ());
if (observedJump >= 15.0f)
TC_LOG_WARN("server.adventurebot.navigation",
"NAV_ANOMALY seq=%llu guid=%u name='%s' kind='position-jump' map=%u jump=%.2f "
"from=(%.3f %.3f %.3f) to=(%.3f %.3f %.3f) target=(%.3f %.3f %.3f) repaths=%u",
static_cast<unsigned long long>(record.Status.NavSequence),
characterGuid, player->GetName().c_str(), player->GetMapId(), observedJump,
record.LastObservedPosition.GetPositionX(),
record.LastObservedPosition.GetPositionY(),
record.LastObservedPosition.GetPositionZ(),
player->GetPositionX(), player->GetPositionY(), player->GetPositionZ(),
record.Status.Target.GetPositionX(),
record.Status.Target.GetPositionY(),
record.Status.Target.GetPositionZ(),
record.Status.RepathCount);
}
record.LastObservedPosition.Relocate(player->GetPosition());
record.HasLastObservedPosition = true;
Position const& target = record.Status.Target;
float const distance = player->GetDistance(
target.GetPositionX(), target.GetPositionY(), target.GetPositionZ());
record.Status.RemainingDistance = distance;
float const deviation = DistanceFromActiveSpline(player, record);
record.Status.MaxObservedDeviation = std::max(record.Status.MaxObservedDeviation, deviation);
TC_LOG_DEBUG("server.adventurebot.navigation",
"NAV_SAMPLE seq=%llu guid=%u segment=%u pos=(%.3f %.3f %.3f) "
"remaining=%.2f deviation=%.2f splineActive=%u repaths=%u",
static_cast<unsigned long long>(record.Status.NavSequence),
characterGuid, record.Status.SegmentIndex,
player->GetPositionX(), player->GetPositionY(), player->GetPositionZ(),
distance, deviation, player->IsSplineEnabled() ? 1u : 0u,
record.Status.RepathCount);
if (deviation > MAX_EXECUTION_DEVIATION)
{
TC_LOG_WARN("server.adventurebot.navigation",
"NAV_ANOMALY seq=%llu guid=%u name='%s' kind='left-planned-path' "
"deviation=%.2f remaining=%.2f repaths=%u",
static_cast<unsigned long long>(record.Status.NavSequence),
characterGuid, player->GetName().c_str(),
deviation, distance, record.Status.RepathCount);
if (record.Status.RepathCount >= MAX_REPATHS)
{
record.Status.State = ClientlessTravelState::Stuck;
player->StopMoving();
player->GetMotionMaster()->Clear();
continue;
}
++record.Status.RepathCount;
LaunchPath(player, record);
continue;
}
if (distance <= ARRIVAL_DISTANCE && !player->IsSplineEnabled())
{
record.Status.State = ClientlessTravelState::Arrived;
record.Status.NoProgressMs = 0;
TC_LOG_INFO("server.adventurebot.navigation",
"NAV_COMPLETE seq=%llu guid=%u name='%s' remaining=%.2f repaths=%u "
"segments=%u maxDeviation=%.2f",
static_cast<unsigned long long>(record.Status.NavSequence),
characterGuid, player->GetName().c_str(), distance,
record.Status.RepathCount, record.Status.SegmentIndex,
record.Status.MaxObservedDeviation);
continue;
}
if (!player->IsSplineEnabled() && !record.ActiveSpline.empty())
{
Position const& segmentEnd = record.ActiveSpline.back();
float const endError = Distance3D(
player->GetPositionX(), player->GetPositionY(), player->GetPositionZ(),
segmentEnd.GetPositionX(), segmentEnd.GetPositionY(), segmentEnd.GetPositionZ());
if (endError <= MAX_SEGMENT_END_ERROR)
{
TC_LOG_INFO("server.adventurebot.navigation",
"NAV_SEGMENT_COMPLETE seq=%llu guid=%u name='%s' segment=%u "
"endError=%.2f remaining=%.2f",
static_cast<unsigned long long>(record.Status.NavSequence),
characterGuid, player->GetName().c_str(),
record.Status.SegmentIndex, endError, distance);
LaunchPath(player, record);
continue;
}
TC_LOG_WARN("server.adventurebot.navigation",
"NAV_ANOMALY seq=%llu guid=%u name='%s' kind='spline-stopped-early' "
"segment=%u endError=%.2f remaining=%.2f",
static_cast<unsigned long long>(record.Status.NavSequence),
characterGuid, player->GetName().c_str(),
record.Status.SegmentIndex, endError, distance);
}
float const progress = record.LastDistance - distance;
if (progress >= MIN_PROGRESS_DISTANCE)
record.Status.NoProgressMs = 0;
else
record.Status.NoProgressMs += elapsed;
record.LastDistance = distance;
if (record.Status.NoProgressMs < REPATH_AFTER_NO_PROGRESS_MS)
continue;
if (record.Status.RepathCount >= MAX_REPATHS)
{
record.Status.State = ClientlessTravelState::Stuck;
player->StopMoving();
player->GetMotionMaster()->Clear();
TC_LOG_WARN("server.adventurebot.navigation",
"NAV_STUCK seq=%llu guid=%u name='%s' map=%u remaining=%.2f repaths=%u "
"lastPathType=0x%X lastPathPoints=%u lastReason='%s'",
static_cast<unsigned long long>(record.Status.NavSequence),
characterGuid, player->GetName().c_str(), player->GetMapId(), distance,
record.Status.RepathCount, record.Status.Path.PathType,
record.Status.Path.PointCount, record.Status.Path.Reason.c_str());
continue;
}
++record.Status.RepathCount;
TC_LOG_WARN("server.adventurebot.navigation",
"NAV_REPATH seq=%llu guid=%u name='%s' map=%u remaining=%.2f "
"noProgressMs=%u repath=%u/%u reason='no-progress'",
static_cast<unsigned long long>(record.Status.NavSequence),
characterGuid, player->GetName().c_str(), player->GetMapId(), distance,
record.Status.NoProgressMs, record.Status.RepathCount, MAX_REPATHS);
LaunchPath(player, record);
}
}
@@ -0,0 +1,92 @@
/*
* Vcore clientless point-travel robustness layer.
* C8.2: arrival detection, progress monitoring, repathing, stuck state.
*/
#ifndef VCORE_CLIENTLESS_TRAVEL_MGR_H
#define VCORE_CLIENTLESS_TRAVEL_MGR_H
#include "Common.h"
#include "Position.h"
#include <string>
#include <unordered_map>
#include <vector>
struct TC_GAME_API ClientlessPathDiagnostics
{
bool Calculated = false;
bool Complete = false;
bool Suspicious = false;
uint32 PathType = 0;
uint32 PointCount = 0;
float DirectDistance = 0.0f;
float PathLength = 0.0f;
float MaxSegmentLength = 0.0f;
float MaxZDelta = 0.0f;
float FinalDistance = 0.0f;
std::string Reason;
};
class Player;
enum class ClientlessTravelState : uint8
{
Idle,
Moving,
Arrived,
Stuck,
InvalidMap,
PathRejected
};
struct TC_GAME_API ClientlessTravelStatus
{
ClientlessTravelState State = ClientlessTravelState::Idle;
uint32 MapId = 0;
Position Target;
float RemainingDistance = 0.0f;
uint32 RepathCount = 0;
uint32 NoProgressMs = 0;
uint64 NavSequence = 0;
uint32 SegmentIndex = 0;
float MaxObservedDeviation = 0.0f;
ClientlessPathDiagnostics Path;
};
class TC_GAME_API ClientlessTravelMgr
{
public:
static ClientlessTravelMgr* instance();
bool StartTravel(Player* player, Position const& target, bool allowGroundMount = false, bool portalApproach = false);
bool AnalyzePath(Player* player, Position const& target, ClientlessPathDiagnostics& diagnostics) const;
void CancelTravel(uint32 characterGuid);
ClientlessTravelStatus const* GetStatus(uint32 characterGuid) const;
void Update(uint32 diff);
static char const* StateName(ClientlessTravelState state);
private:
// C8.11b: hardened explicit MMAP local navigation.
struct TravelRecord
{
ClientlessTravelStatus Status;
float LastDistance = 0.0f;
Position LastObservedPosition;
bool HasLastObservedPosition = false;
uint32 CheckTimerMs = 0;
std::vector<Position> ActiveSpline;
bool AllowGroundMount = false;
// VCORE_VIRTUAL_EDGE_V11_PORTAL_APPROACH: only actual portal source steps.
bool PortalApproach = false;
};
bool LaunchPath(Player* player, TravelRecord& record);
float DistanceFromActiveSpline(Player const* player, TravelRecord const& record) const;
std::unordered_map<uint32, TravelRecord> _records;
uint64 _nextNavSequence = 1;
};
#define sClientlessTravelMgr ClientlessTravelMgr::instance()
#endif
@@ -0,0 +1,122 @@
#include "ClientlessWorldPointMgr.h"
#include "DatabaseEnv.h"
#include "Log.h"
#include "Player.h"
#include <cmath>
#include <limits>
ClientlessWorldPointMgr* ClientlessWorldPointMgr::instance()
{
static ClientlessWorldPointMgr instance;
return &instance;
}
bool ClientlessWorldPointMgr::EnsureLoaded()
{
if (_loaded)
return true;
return Reload();
}
bool ClientlessWorldPointMgr::Reload()
{
_points.clear();
QueryResult result = WorldDatabase.Query(
"SELECT id, point_key, category, map, position_x, position_y, position_z, orientation "
"FROM vcore_clientless_world_point WHERE enabled = 1 ORDER BY id");
if (!result)
{
_loaded = true;
TC_LOG_WARN("server.adventurebot",
"[AdventureBot] loaded 0 clientless world points.");
return false;
}
do
{
Field* fields = result->Fetch();
ClientlessWorldPoint point;
point.DatabaseId = fields[0].GetUInt32();
point.Id = fields[1].GetString();
point.Category = fields[2].GetString();
point.MapId = fields[3].GetUInt16();
point.X = fields[4].GetFloat();
point.Y = fields[5].GetFloat();
point.Z = fields[6].GetFloat();
point.O = fields[7].GetFloat();
if (point.Id.empty() || point.Category.empty())
continue;
_points.push_back(std::move(point));
}
while (result->NextRow());
_loaded = true;
TC_LOG_INFO("server.adventurebot",
"[AdventureBot] loaded %u clientless world points.",
uint32(_points.size()));
return true;
}
ClientlessWorldPoint const* ClientlessWorldPointMgr::FindNearest(
Player const* player,
ClientlessWorldPointQuery const& query)
{
if (!player || !player->IsInWorld())
return nullptr;
EnsureLoaded();
float const maxDistance = query.MaxDistance > 0.0f
? query.MaxDistance
: DefaultSearchRadius;
ClientlessWorldPoint const* best = nullptr;
float bestDistance = std::numeric_limits<float>::max();
for (ClientlessWorldPoint const& point : _points)
{
if (point.MapId != player->GetMapId())
continue;
if (!query.Id.empty() && point.Id != query.Id)
continue;
if (!query.Category.empty() && point.Category != query.Category)
continue;
float const dx = point.X - player->GetPositionX();
float const dy = point.Y - player->GetPositionY();
float const dz = point.Z - player->GetPositionZ();
float const distance = std::sqrt(dx * dx + dy * dy + dz * dz);
if (distance > maxDistance || distance >= bestDistance)
continue;
best = &point;
bestDistance = distance;
}
return best;
}
ClientlessWorldPoint const* ClientlessWorldPointMgr::FindById(std::string const& id)
{
if (id.empty())
return nullptr;
EnsureLoaded();
for (ClientlessWorldPoint const& point : _points)
if (point.Id == id)
return &point;
return nullptr;
}
@@ -0,0 +1,57 @@
/*
* Vcore AdventureBot local semantic world points.
*/
#ifndef VCORE_CLIENTLESS_WORLD_POINT_MGR_H
#define VCORE_CLIENTLESS_WORLD_POINT_MGR_H
#include "Common.h"
#include <string>
#include <vector>
class Player;
struct TC_GAME_API ClientlessWorldPoint
{
uint32 DatabaseId = 0;
std::string Id;
std::string Category;
uint32 MapId = 0;
float X = 0.0f;
float Y = 0.0f;
float Z = 0.0f;
float O = 0.0f;
};
struct TC_GAME_API ClientlessWorldPointQuery
{
std::string Id;
std::string Category;
float MaxDistance = 0.0f;
};
class TC_GAME_API ClientlessWorldPointMgr
{
public:
static ClientlessWorldPointMgr* instance();
bool Reload();
bool EnsureLoaded();
ClientlessWorldPoint const* FindNearest(
Player const* player,
ClientlessWorldPointQuery const& query);
ClientlessWorldPoint const* FindById(std::string const& id);
uint32 GetPointCount() const { return uint32(_points.size()); }
float GetDefaultSearchRadius() const { return DefaultSearchRadius; }
private:
static constexpr float DefaultSearchRadius = 400.0f;
bool _loaded = false;
std::vector<ClientlessWorldPoint> _points;
};
#define sClientlessWorldPointMgr ClientlessWorldPointMgr::instance()
#endif
+282
View File
@@ -20,6 +20,8 @@
*/
#include "World.h"
#include "ClientlessPlayerMgr.h"
#include "ClientlessRouteRecorderMgr.h"
#include "AccountMgr.h"
#include "AchievementMgr.h"
#include "AddonMgr.h"
@@ -179,6 +181,14 @@ World::~World()
m_sessions.erase(m_sessions.begin());
}
// Clientless sessions are independently owned by World.
while (!m_clientlessSessions.empty())
{
delete m_clientlessSessions.begin()->second;
m_clientlessSessions.erase(m_clientlessSessions.begin());
}
m_clientlessPlayerStates.clear();
CliCommandHolder* command = nullptr;
while (cliCmdQueue.next(command))
delete command;
@@ -307,6 +317,190 @@ WorldSession* World::FindSession(uint32 id) const
}
/// Remove a given session
WorldSession* World::FindClientlessSession(uint32 characterGuid) const
{
SessionMap::const_iterator itr = m_clientlessSessions.find(characterGuid);
if (itr != m_clientlessSessions.end())
return itr->second;
return nullptr;
}
ClientlessPlayerState World::GetClientlessPlayerState(uint32 characterGuid) const
{
auto itr = m_clientlessPlayerStates.find(characterGuid);
if (itr == m_clientlessPlayerStates.end())
return ClientlessPlayerState::Offline;
return itr->second;
}
bool World::AddClientlessSession(uint32 characterGuid, WorldSession* session)
{
if (!characterGuid || !session)
return false;
if (!session->IsClientless())
{
TC_LOG_ERROR("server.worldserver", "World::AddClientlessSession rejected non-clientless session for character GUID %u.", characterGuid);
return false;
}
if (session->GetPlayer() && session->GetGUIDLow() != characterGuid)
{
TC_LOG_ERROR("server.worldserver",
"World::AddClientlessSession rejected character GUID %u because the attached player has GUID %u.",
characterGuid, uint32(session->GetGUIDLow()));
return false;
}
if (m_clientlessSessions.find(characterGuid) != m_clientlessSessions.end())
{
TC_LOG_ERROR("server.worldserver", "World::AddClientlessSession rejected duplicate character GUID %u.", characterGuid);
return false;
}
for (SessionMap::const_iterator itr = m_clientlessSessions.begin(); itr != m_clientlessSessions.end(); ++itr)
{
if (itr->second == session)
{
TC_LOG_ERROR("server.worldserver",
"World::AddClientlessSession rejected session already registered for character GUID %u.",
itr->first);
return false;
}
}
m_clientlessSessions[characterGuid] = session;
m_clientlessPlayerStates[characterGuid] = ClientlessPlayerState::Loading;
TC_LOG_INFO("server.worldserver",
"Registered clientless session for character GUID %u (account %u), state=Loading.",
characterGuid, session->GetAccountId());
return true;
}
bool World::RemoveClientlessSession(uint32 characterGuid)
{
SessionMap::iterator itr = m_clientlessSessions.find(characterGuid);
if (itr == m_clientlessSessions.end())
return false;
ClientlessPlayerState& state = m_clientlessPlayerStates[characterGuid];
if (state == ClientlessPlayerState::Removing)
return true;
state = ClientlessPlayerState::Removing;
TC_LOG_INFO("server.worldserver",
"Clientless removal requested for character GUID %u (account %u), state=Removing.",
characterGuid, itr->second ? itr->second->GetAccountId() : 0u);
return true;
}
bool World::CreateClientlessPlayerSession(uint32 accountId, uint32 characterGuid)
{
if (!accountId || !characterGuid)
{
TC_LOG_ERROR("server.worldserver",
"World::CreateClientlessPlayerSession rejected account %u, character GUID %u.",
accountId, characterGuid);
return false;
}
if (GetClientlessPlayerState(characterGuid) != ClientlessPlayerState::Offline || FindClientlessSession(characterGuid))
{
TC_LOG_ERROR("server.worldserver",
"World::CreateClientlessPlayerSession rejected character GUID %u because its lifecycle is not Offline.",
characterGuid);
return false;
}
if (FindSession(accountId))
{
TC_LOG_ERROR("server.worldserver",
"World::CreateClientlessPlayerSession rejected account %u because a network session is active.",
accountId);
return false;
}
QueryResult characterResult = CharacterDatabase.PQuery(
"SELECT account FROM characters WHERE guid = %u", characterGuid);
if (!characterResult)
{
TC_LOG_ERROR("server.worldserver",
"World::CreateClientlessPlayerSession could not find character GUID %u.",
characterGuid);
return false;
}
uint32 characterAccountId = characterResult->Fetch()[0].GetUInt32();
if (characterAccountId != accountId)
{
TC_LOG_ERROR("server.worldserver",
"World::CreateClientlessPlayerSession rejected character GUID %u: character account %u does not match requested account %u.",
characterGuid, characterAccountId, accountId);
return false;
}
QueryResult accountResult = LoginDatabase.PQuery(
"SELECT username, expansion, mutetime, locale, recruiter FROM account WHERE id = %u",
accountId);
if (!accountResult)
{
TC_LOG_ERROR("server.worldserver",
"World::CreateClientlessPlayerSession could not find account %u.",
accountId);
return false;
}
Field* accountFields = accountResult->Fetch();
std::string accountName = accountFields[0].GetString();
uint8 expansion = accountFields[1].GetUInt8();
time_t muteTime = time_t(accountFields[2].GetUInt64());
LocaleConstant locale = LocaleConstant(accountFields[3].GetUInt8());
uint32 recruiter = accountFields[4].GetUInt32();
if (locale >= TOTAL_LOCALES)
locale = LOCALE_enUS;
QueryResult recruiterResult = LoginDatabase.PQuery(
"SELECT 1 FROM account WHERE recruiter = %u LIMIT 1", accountId);
bool isRecruiter = bool(recruiterResult);
AccountTypes security = AccountTypes(AccountMgr::GetSecurity(accountId, realm.Id.Realm));
WorldSession* session = new WorldSession(
accountId,
std::move(accountName),
nullptr,
security,
expansion,
muteTime,
locale,
recruiter,
isRecruiter,
WorldSession::WorldSessionMode::Clientless);
if (!AddClientlessSession(characterGuid, session))
{
delete session;
return false;
}
ObjectGuid playerGuid = ObjectGuid::Create<HighGuid::Player>(characterGuid);
if (!session->InitializeClientlessPlayer(playerGuid))
{
m_clientlessSessions.erase(characterGuid);
m_clientlessPlayerStates.erase(characterGuid);
delete session;
return false;
}
TC_LOG_INFO("server.worldserver",
"Created clientless player session for character GUID %u (account %u).",
characterGuid, accountId);
return true;
}
bool World::RemoveSession(uint32 id)
{
///- Find the session, kick the user, but we can't delete session at this moment to prevent iterator invalidation
@@ -337,6 +531,19 @@ void World::AddSession_(WorldSession* s)
{
ASSERT(s);
for (SessionMap::const_iterator itr = m_clientlessSessions.begin(); itr != m_clientlessSessions.end(); ++itr)
{
if (itr->second && itr->second->GetAccountId() == s->GetAccountId())
{
TC_LOG_ERROR("server.worldserver",
"World::AddSession_ rejected network session for account %u because clientless character GUID %u is active.",
s->GetAccountId(), itr->first);
s->KickPlayer();
delete s;
return;
}
}
//NOTE - Still there is race condition in WorldSession* being used in the Sockets
///- kick already loaded player with same account (if any) and remove session
@@ -3182,6 +3389,81 @@ void World::UpdateSessions(uint32 diff)
}
}
// High-level clientless controllers observe state before the low-level
// lifecycle loop may destroy a Removing session.
sClientlessPlayerMgr->Update(diff);
sClientlessRouteRecorderMgr->Update(diff);
// Clientless sessions share normal WorldSession callbacks/update machinery,
// but are intentionally outside the network session registry and queue.
// Explicit states keep async initialization/player loading from being
// destroyed underneath an in-flight callback.
for (SessionMap::iterator itr = m_clientlessSessions.begin(), next; itr != m_clientlessSessions.end(); itr = next)
{
next = itr;
++next;
uint32 characterGuid = itr->first;
WorldSession* session = itr->second;
if (!session)
{
m_clientlessPlayerStates.erase(characterGuid);
m_clientlessSessions.erase(itr);
continue;
}
session->CompleteClientlessTeleport();
WorldSessionFilter updater(session);
if (!session->Update(diff, updater))
{
m_clientlessPlayerStates.erase(characterGuid);
delete session;
m_clientlessSessions.erase(itr);
continue;
}
ClientlessPlayerState& state = m_clientlessPlayerStates[characterGuid];
if (state == ClientlessPlayerState::Loading)
{
if (Player* player = session->GetPlayer())
{
if (player->IsInWorld())
{
state = ClientlessPlayerState::Online;
TC_LOG_INFO("server.worldserver",
"Clientless player character GUID %u (account %u) reached state=Online.",
characterGuid, session->GetAccountId());
}
}
else if (!session->HasPendingClientlessPlayerLogin())
{
TC_LOG_ERROR("server.worldserver",
"Clientless player character GUID %u (account %u) left Loading without a Player; cleaning up.",
characterGuid, session->GetAccountId());
m_clientlessPlayerStates.erase(characterGuid);
delete session;
m_clientlessSessions.erase(itr);
}
continue;
}
if (state == ClientlessPlayerState::Removing)
{
if (session->HasPendingClientlessPlayerLogin())
continue;
TC_LOG_INFO("server.worldserver",
"Clientless player character GUID %u (account %u) reached state=Offline.",
characterGuid, session->GetAccountId());
m_clientlessPlayerStates.erase(characterGuid);
delete session;
m_clientlessSessions.erase(itr);
}
}
}
// This handles the issued and queued CLI commands
+21 -1
View File
@@ -596,6 +596,14 @@ struct CharacterInfo
uint32 ArenaTeamId[3];
};
enum class ClientlessPlayerState : uint8
{
Offline,
Loading,
Online,
Removing
};
/// The World
class TC_GAME_API World
{
@@ -606,6 +614,16 @@ class TC_GAME_API World
WorldSession* FindSession(uint32 id) const;
void AddSession(WorldSession* s);
// Clientless sessions are owned by World but intentionally kept
// outside m_sessions, which remains the network/account registry.
WorldSession* FindClientlessSession(uint32 characterGuid) const;
bool AddClientlessSession(uint32 characterGuid, WorldSession* session);
bool RemoveClientlessSession(uint32 characterGuid);
bool CreateClientlessPlayerSession(uint32 accountId, uint32 characterGuid);
ClientlessPlayerState GetClientlessPlayerState(uint32 characterGuid) const;
SessionMap const& GetAllClientlessSessions() const { return m_clientlessSessions; }
uint32 GetClientlessSessionCount() const { return uint32(m_clientlessSessions.size()); }
void AddInstanceSocket(std::weak_ptr<WorldSocket> sock, uint64 connectToKey);
void SendAutoBroadcast();
bool RemoveSession(uint32 id);
@@ -855,7 +873,9 @@ class TC_GAME_API World
time_t mail_timer;
time_t mail_timer_expires;
SessionMap m_sessions;
SessionMap m_sessions; // Network sessions, keyed by account id
SessionMap m_clientlessSessions; // Clientless sessions, keyed by character low GUID
std::unordered_map<uint32, ClientlessPlayerState> m_clientlessPlayerStates;
typedef std::unordered_map<uint32, time_t> DisconnectMap;
DisconnectMap m_disconnects;
uint32 m_maxActiveSessionCount;
@@ -0,0 +1,3 @@
-- Generic AdventureBot routine: semantic intent only.
log("generic adventure routine loaded for " .. character_name)
intend("idle", { routine = "generic", purpose = "await_planner" })
@@ -0,0 +1,2 @@
-- Travel acceptance completed. Wait for explicit actions until daily behavior is configured.
log("Arthoran ready; no automatic test journey queued")
@@ -0,0 +1,45 @@
-- Arthoran: first named AdventureBot character profile.
-- These values bias future weighted decisions; they do not prescribe a route.
return {
identity = {
name = "Arthoran",
home_region = "elwynn",
},
movement = {
can_ground_mount = true,
can_free_fly = true,
flight_mounts = { [32235] = 100 }, -- Golden Gryphon, learned mount spell
ground_mount_min_distance = 45,
ground_mount_speed_rate = 1.60,
-- [mount display ID] = random weight
ground_mounts = {
[2404] = 100, -- Brown Horse
},
},
personality = {
sociability = 0.65,
adventurousness = 0.70,
wanderlust = 0.30,
tavern_affinity = 0.85,
wilderness_affinity = 0.65,
city_affinity = 0.35,
danger_tolerance = 0.70,
},
region_affinity = {
elwynn = 1.00,
stormwind = 0.70,
dun_morogh = 0.35,
blasted_lands = 0.65,
},
activity_affinity = {
tavern = 0.90,
hunting = 0.75,
travelling = 0.35,
city_visit = 0.35,
social = 0.60,
},
}
@@ -0,0 +1,25 @@
-- C8.12 generic AdventureBot character profile.
-- Character data/capabilities, not a hard-coded daily route.
return {
identity = {
name = character_name,
home_region = "",
},
movement = {
can_ground_mount = true,
ground_mount_min_distance = 60,
ground_mount_speed_rate = 1.60,
ground_mounts = {
[2404] = 100, -- Brown Horse display ID
},
},
personality = {
sociability = 0.50,
adventurousness = 0.50,
wanderlust = 0.50,
},
region_affinity = {},
activity_affinity = {},
}
@@ -0,0 +1,4 @@
-- Minimal C7 BugLab/TestBot API smoke script.
log("test smoke script loaded for " .. character_name)
intend("test", { suite = "smoke", purpose = "verify_lua_bridge" })
queue_action("marker", { name = "c7_smoke" })
File diff suppressed because it is too large. Load diff
@@ -68,6 +68,15 @@
RealmID = 1
#
# Clientless.ScriptRoot
# Description: Lua root read directly on login and .clientless luareload <GUID>.
# Set an absolute source directory for development without installing scripts.
# VCORE_CLIENTLESS_SCRIPT_DIR overrides this setting if nonempty.
# Restart once after changing the root; Lua edits require no restart.
# Default: "scripts/clientless" (relative to the worldserver working directory)
Clientless.ScriptRoot = "scripts/clientless"
# DataDir
# Description: Data directory setting.
# Important: DataDir needs to be quoted, as the string might contain space characters.
@@ -4322,3 +4331,12 @@ Metric.ConnectionInfo = "127.0.0.1;8086;worldserver"
Metric.OverallStatusInterval = 1
###################################################################################################
# Dynamic free flight is profile opt-in. No free-flight nodes/edges are stored.
Clientless.Flight.Enabled = 1
# Desired clearance, not strict terrain following; hard safety/ceiling checks remain.
Clientless.Flight.Clearance = 60
# RP penalty normally favors roads. Zone IDs below prefer free flight, including
# the final node/virtual-exit -> location leg (Icecrown, Wintergrasp).
Clientless.Flight.MinDistance = 600
Clientless.Flight.PreferredZones = "210 4197"
+92
View File
@@ -0,0 +1,92 @@
"""Exercise production Update with a running native spline, not a string check."""
from pathlib import Path
import subprocess
import tempfile
root = Path(__file__).resolve().parents[2]
source = (root / 'src/server/game/World/ClientlessFlightMgr.cpp').read_text()
a = source.index('void ClientlessFlightMgr::Update(uint32 diff)')
b = source.index('bool ClientlessFlightMgr::Command(', a)
update = source[a:b]
harness = r'''
#include "ClientlessFlightPath.h"
#include <cassert>
#include <cstdint>
#include <string>
#include <unordered_map>
using uint32 = uint32_t;
using namespace ClientlessFlightPath;
struct Position {
float x=0,y=0,z=60;
float GetPositionX() const{return x;} float GetPositionY() const{return y;} float GetPositionZ() const{return z;}
void Relocate(Position const* p){*this=*p;}
};
Point PointOf(Position const& p){return {p.x,p.y,p.z};}
struct Spline {int remaining=500; int timeRemaining()const{return remaining;}};
struct Player:Position {
Spline spline; Spline* movespline=&spline;
bool alive=true,fly=true,running=true;int stops=0;
bool IsInWorld(){return true;} uint32 GetMapId(){return 0;}
bool IsAlive(){return alive;} bool IsBeingTeleported(){return false;}
bool IsMounted(){return true;} bool CanFly(){return fly;}
bool IsSplineEnabled(){return running;}
void StopMoving(){++stops;running=false;}
} player;
struct Session {Player* GetPlayer(){return &player;}} session;
enum class ClientlessPlayerState {Online};
struct World {
Session* FindClientlessSession(uint32){return &session;}
ClientlessPlayerState GetClientlessPlayerState(uint32){return ClientlessPlayerState::Online;}
} world;
auto sWorld=&world;
enum class ClientlessFlightState {Mounting,Flying,Arrived,Failed};
struct Record {
struct {ClientlessFlightState State=ClientlessFlightState::Flying;uint32 MapId=0;Position Landing{800,0,0};float Travelled=0;} Status;
Position LastPosition,Start,Segment{25,0,60};Settings Settings;
uint32 Elapsed=0,SegmentElapsed=0,CheckTimer=0;
bool Moving=true,Returning=false;
};
bool clear=true;
bool ClearRay(Player*,Point const&,Point const&){return clear;}
class ClientlessFlightMgr {
public:
std::unordered_map<uint32,Record> _records;
int advances=0,handoffs=0,failures=0;
Surface Sample(Player*,Record&,float,float){return {0,300,true};}
void Fail(Player*,Record& r,std::string const&){++failures;r.Status.State=ClientlessFlightState::Failed;}
bool Advance(Player*,Record&,bool handoff=false){++advances;handoffs+=handoff;return true;}
void Update(uint32 diff);
};
'''.replace('Settings Settings;', 'ClientlessFlightPath::Settings Settings;')
harness += update + r'''
int main(){
ClientlessFlightMgr mgr;mgr._records[26]={};
// Begin replacement while the existing spline is still active: no stop.
mgr.Update(50);assert(mgr.handoffs==1&&mgr.advances==1&&player.stops==0);
// The 200ms collision cadence must not launch a replacement each tick.
mgr.Update(50);assert(mgr.advances==1);
mgr._records[26].CheckTimer=0;player.spline.remaining=900;
mgr.Update(50);assert(mgr.advances==1);
// Exact landing endpoint must finish normally, never repeatedly extend.
auto& r=mgr._records[26];r.CheckTimer=0;r.Status.Landing=r.Segment;
player.spline.remaining=400;mgr.Update(50);assert(mgr.advances==1);
// Reverse trail keeps its original checked corners.
r.Segment={25,0,60};r.Returning=true;r.CheckTimer=0;
mgr.Update(50);assert(mgr.advances==1);
// Distance means measured motion, not unflown overlapping destinations.
player.x=7;r.CheckTimer=0;mgr.Update(50);assert(std::fabs(r.Status.Travelled-7)<0.01);
// A newly blocked corridor still stops/replans immediately.
clear=false;r.CheckTimer=0;mgr.Update(50);
assert(player.stops==1&&mgr.advances==2&&mgr.handoffs==1);
// A completed spline continues via the normal non-handoff path.
clear=true;r.CheckTimer=0;mgr.Update(50);assert(mgr.advances==3);
player.fly=false;mgr.Update(50);assert(mgr.failures==1);
}
'''
with tempfile.TemporaryDirectory(prefix='vcore-flight-handoff-') as folder:
cpp = Path(folder) / 'test.cpp'
binary = Path(folder) / 'test'
cpp.write_text(harness)
subprocess.run(['c++', '-std=c++17', '-I', str(root/'src/server/game/World'), str(cpp), '-o', str(binary)], check=True)
subprocess.run([str(binary)], check=True)
print('Flight handoff before spline end, terminal landing, return corners, collision stop and actual distance passed.')
+98
View File
@@ -0,0 +1,98 @@
"""Compile production flight eligibility/cost and fail-safe abort decisions with Core doubles."""
from pathlib import Path
import subprocess,tempfile
root=Path(__file__).resolve().parents[2]
source=(root/'src/server/game/World/ClientlessFlightMgr.cpp').read_text()
def function(signature):
a=source.index(signature);opening=source.index('{',a);depth=1;b=opening+1
while depth:
depth+=(source[b]=='{')-(source[b]=='}');b+=1
return source[a:b]
harness=r'''
#include "ClientlessFlightPath.h"
#include <cassert>
#include <cstdint>
#include <string>
#include <vector>
#include <sstream>
#include <limits>
using uint32=uint32_t;
using namespace ClientlessFlightPath;
#define TC_LOG_WARN(...) ((void)0)
constexpr int SKILL_RIDING=762,MAP_COMMON=0,SPELL_AURA_MOUNT_RESTRICTIONS=1;
struct Position{float x=0,y=0,z=0;float GetPositionX()const{return x;}float GetPositionY()const{return y;}float GetPositionZ()const{return z;}};
Point PointOf(Position const&p){return {p.x,p.y,p.z};}float Infinity(){return std::numeric_limits<float>::infinity();}
struct Guid{uint32 GetCounter()const{return 26;}};
struct Player{
bool license=true,cold=true,learned=true,alive=true,canFly=true,restrictions=false;
uint32 skill=225,map=0,area=87;int stops=0,interrupts=0;
Guid GetGUID()const{return {};}
uint32 GetSkillValue(int)const{return skill;}
bool HasSpell(uint32 s)const{return s==90267?license:s==54197?cold:learned;}
uint32 GetMapId()const{return map;}uint32 GetAreaId()const{return area;}
bool HasAuraType(int)const{return restrictions;}
bool CanFly(){return canFly;}bool IsAlive(){return alive;}
void StopMoving(){++stops;}void InterruptNonMeleeSpells(bool){++interrupts;}
float GetPositionX(){return 0;}float GetPositionY(){return 0;}float GetPositionZ(){return 50;}
};
struct Mount{uint32 DisplayId=32235,Weight=100;};
struct Profile{bool CanFreeFly=true;std::vector<Mount> FlightMounts{{}};} profile;
struct Lua{auto GetAdventureProfile(uint32){return &profile;}} lua;
struct Config{bool enabled=true;bool GetBoolDefault(const char*,bool){return enabled;}float GetFloatDefault(const char*,float v){return v;}std::string GetStringDefault(const char*,const char*v){return v;}} config;
auto sClientlessLuaEngine=&lua;auto sConfigMgr=&config;
struct SpellMgr{void* GetSpellInfo(uint32){return this;}} spells;auto sSpellMgr=&spells;
uint32 FlightType(void*){return 1;}
struct MapEntry{uint32 MapType=0;} entry;struct Maps{auto LookupEntry(uint32){return &entry;}} maps;auto& sMapStore=maps;
enum class AreaMountFlags{AllowFlyingMounts};
struct Flags{bool fly=true;bool HasFlag(AreaMountFlags){return fly;}};
struct Area{bool fly=true;uint32 World_pvp_ID=0;Flags GetMountFlags()const{return {fly};}} area;
struct Areas{bool known=true;auto LookupEntry(uint32){return known?&area:nullptr;}} areas;auto& sAreaTableStore=areas;
enum class ClientlessFlightState{Mounting,Flying,Arrived,Failed};
struct Record{
struct{ClientlessFlightState State=ClientlessFlightState::Flying;uint32 MapId=0;std::string Detail;}Status;
bool Moving=true,Returning=false;std::vector<Position> Breadcrumbs{{}};std::string AbortReason;
};
class ClientlessFlightMgr{public:
uint32 areaCode=87,zoneCode=12;
std::pair<uint32,uint32> AreaZone(Player const*,uint32,Position const&)const{return {areaCode,zoneCode};}
bool Enabled(Player const*)const;bool Preferred(Player const*,uint32,Position const&)const;
float Estimate(Player const*,uint32,Position const&,Position const&)const;
void Fail(Player*,Record&,std::string const&);
};
'''
for signature in ('bool ClientlessFlightMgr::Enabled(', 'bool ClientlessFlightMgr::Preferred(', 'float ClientlessFlightMgr::Estimate(', 'void ClientlessFlightMgr::Fail('):
harness+=function(signature)+'\n'
harness+=r'''
int main(){
ClientlessFlightMgr mgr;Player p;Position a,b;b.x=1000;
float roadBias=mgr.Estimate(&p,0,a,b);assert(roadBias>1000&&std::isfinite(roadBias));
p.license=false;assert(!std::isfinite(mgr.Estimate(&p,0,a,b)));p.license=true;
p.cold=false;assert(!std::isfinite(mgr.Estimate(&p,571,a,b)));p.cold=true;
p.skill=150;assert(!mgr.Enabled(&p));p.skill=225;
p.learned=false;assert(!mgr.Enabled(&p));p.learned=true;
profile.CanFreeFly=false;assert(!mgr.Enabled(&p));profile.CanFreeFly=true;
entry.MapType=1;assert(!std::isfinite(mgr.Estimate(&p,0,a,b)));entry.MapType=0;
// Eversong/Ghostlands and Deeprun use native mount flags 13, without bit 2.
area.fly=false;assert(!std::isfinite(mgr.Estimate(&p,530,a,b)));
assert(!std::isfinite(mgr.Estimate(&p,369,a,b)));
area.fly=true;mgr.zoneCode=210;
assert(mgr.Estimate(&p,571,a,b)<roadBias); // explicit zone preference
b.x=90;assert(std::isfinite(mgr.Estimate(&p,571,a,b)));
mgr.zoneCode=12;assert(!std::isfinite(mgr.Estimate(&p,0,a,b)));
// Unknown metadata is not an invented blanket prohibition; actual native
// mount capability/cast at departure still decides in Start().
areas.known=false;b.x=1000;assert(std::isfinite(mgr.Estimate(&p,0,a,b)));areas.known=true;
area.fly=false;area.World_pvp_ID=1;assert(std::isfinite(mgr.Estimate(&p,571,a,b)));area.World_pvp_ID=0;
// An airborne abort first returns. A blocked return must not dismount/drop.
Record r;mgr.Fail(&p,r,"blocked");assert(r.Returning&&r.Status.State==ClientlessFlightState::Flying&&p.stops==1);
mgr.Fail(&p,r,"reverse blocked");assert(r.Status.State==ClientlessFlightState::Failed);
Record mounting;mounting.Status.State=ClientlessFlightState::Mounting;mgr.Fail(&p,mounting,"cast rejected");
assert(mounting.Status.State==ClientlessFlightState::Failed&&p.interrupts==1&&!mounting.Returning);
Record revoked;p.canFly=false;mgr.Fail(&p,revoked,"permission lost");assert(revoked.Status.State==ClientlessFlightState::Failed&&!revoked.Returning);
}
'''
with tempfile.TemporaryDirectory(prefix='vcore-flight-policy-') as folder:
cpp=Path(folder)/'test.cpp';binary=Path(folder)/'test';cpp.write_text(harness)
subprocess.run(['c++','-std=c++17','-I',str(root/'src/server/game/World'),str(cpp),'-o',str(binary)],check=True)
subprocess.run([str(binary)],check=True)
print('Flight native eligibility, license/profile/zone policy, RP costs and fail-safe return decisions passed.')
+67
View File
@@ -0,0 +1,67 @@
"""Run the production 3D flight geometry against deterministic terrain/collisions."""
from pathlib import Path
import subprocess,tempfile
root=Path(__file__).resolve().parents[2]
harness=r'''
#include "ClientlessFlightPath.h"
#include <cassert>
#include <iostream>
using namespace ClientlessFlightPath;
struct Run {Point End;float Peak=0,Side=0;unsigned Steps=0,Avoidances=0;bool Arrived=false;};
Run simulate(Point start,Point target,Sample sample,Ray ray,int limit=500){
Settings cfg;Memory mem;mem.Departed=start.Z-sample(start.X,start.Y).Ground>cfg.Safety;Run r;Point p=start;
for(int i=0;i<limit;++i){
auto next=Next(p,target,cfg,mem,sample,ray);
if(!next.Valid)break;
assert(Corridor(p,next.Next,target,cfg,sample,ray,p.Z-sample(p.X,p.Y).Ground<cfg.Safety));
assert(next.Next.Z+5<=sample(next.Next.X,next.Next.Y).Ceiling);
p=next.Next;++r.Steps;r.Peak=std::max(r.Peak,p.Z);r.Side=std::max(r.Side,std::fabs(p.Y));r.Avoidances+=next.Avoiding;
if(Distance(p,target)<0.8f){r.Arrived=true;break;}
}
r.End=p;return r;
}
int main(){
auto flat=[](float,float){return Surface{0,300,true};};
auto clear=[](Point const&,Point const&){return true;};
auto normal=simulate({0,0,0},{800,0,0},flat,clear);
assert(normal.Arrived&&normal.Peak>30&&normal.Peak<=60.1f);
auto low=[](float,float){return Surface{0,42,true};};
auto ceiling=simulate({0,0,0},{800,0,0},low,clear);
assert(ceiling.Arrived&&ceiling.Peak<=36.1f);
auto peak=[](float x,float){return Surface{x>390&&x<400?40.0f:0.0f,300,true};};
auto smooth=simulate({0,0,0},{800,0,0},peak,clear);
assert(smooth.Arrived&&smooth.Peak<75); // no nominal clearance chase over short peak
auto wall=[](Point const&a,Point const&b){
for(int i=0;i<=80;++i){auto p=Mix(a,b,i/80.0f);if(p.X>=240&&p.X<=260&&std::fabs(p.Y)<16&&p.Z<110)return false;}return true;
};
auto side=simulate({0,0,0},{800,0,0},flat,wall);
assert(side.Arrived&&side.Avoidances>0&&side.Side>10);
auto wide=[](Point const&a,Point const&b){
for(int i=0;i<=80;++i){auto p=Mix(a,b,i/80.0f);if(p.X>=240&&p.X<=260&&std::fabs(p.Y)<500&&p.Z<95)return false;}return true;
};
auto climb=simulate({0,0,0},{800,0,0},flat,wide);
assert(climb.Arrived&&climb.Peak>95&&climb.Avoidances>0);
auto unknown=[](float x,float){return Surface{0,300,x<120};};
assert(!simulate({0,0,0},{800,0,0},unknown,clear).Arrived);
auto blocked=[](Point const&,Point const&){return false;};
assert(!simulate({0,0,0},{800,0,0},flat,blocked).Arrived);
// Missing/covered/wrong-floor landing is not an arrival.
auto invalidEnd=[](float x,float){return Surface{x>790?20.0f:0.0f,300,true};};
assert(!simulate({0,0,0},{800,0,0},invalidEnd,clear).Arrived);
auto shortFlight=simulate({0,0,0},{90,0,0},flat,clear);
assert(shortFlight.Arrived&&shortFlight.Peak>5);
// An avoidance may leave the bot high immediately above the landing site.
// Open a descending approach instead of oscillating over the target.
Memory landingMemory;landingMemory.Departed=true;
Point landingStart{660,0,60};
auto firstDescent=Next(landingStart,{800,0,0},Settings{},landingMemory,flat,clear);
assert(firstDescent.Valid&&firstDescent.Next.X>660&&firstDescent.Next.Z<60);
auto highApproach=simulate({0,0,70},{1,0,0},flat,clear);
assert(highApproach.Arrived&&highApproach.Steps<20&&highApproach.Peak<=70);
std::cout<<"Flight geometry passed: takeoff/landing, smoothed peak, ceiling, lateral/climbing avoidance, missing terrain and blocked corridors.\n";
}
'''
with tempfile.TemporaryDirectory(prefix='vcore-free-flight-') as folder:
cpp=Path(folder)/'test.cpp';binary=Path(folder)/'test';cpp.write_text(harness)
subprocess.run(['c++','-std=c++17','-I',str(root/'src/server/game/World'),str(cpp),'-o',str(binary)],check=True)
subprocess.run([str(binary)],check=True)
@@ -0,0 +1,112 @@
from pathlib import Path
import subprocess
import tempfile
root=Path(__file__).resolve().parents[2]
h=(root/'src/server/game/World/ClientlessTravelGraphMgr.h').read_text()
s=(root/'src/server/game/World/ClientlessTravelGraphMgr.cpp').read_text()
structs=h[h.index('struct TC_GAME_API ClientlessTravelNode'):h.index('class TC_GAME_API ClientlessTravelGraphMgr')].replace('TC_GAME_API ','')
method=s[s.index('bool ClientlessTravelGraphMgr::PlanAdaptiveRoute('):s.index('bool ClientlessTravelGraphMgr::PlanRouteToPosition(')]
signature=h[h.index(' bool PlanAdaptiveRoute('):h.index(' bool FindNearestNodeToPosition(')]
preamble=r'''
#include <algorithm>
#include <cassert>
#include <cmath>
#include <cstdint>
#include <iostream>
#include <limits>
#include <queue>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
using uint32=uint32_t; using int32=int32_t;
#define TC_LOG_INFO(...) do {} while(0)
#define TC_LOG_DEBUG(...) do {} while(0)
#define TC_LOG_WARN(...) do {} while(0)
constexpr uint32 NAV_GROUND=1, PATHFIND_INCOMPLETE=2, PATHFIND_NOPATH=4;
struct Position { float x=0,y=0,z=0; void Relocate(float a,float b,float c,float=0){x=a;y=b;z=c;} };
struct Player { float x=0,y=0,z=0; bool IsInWorld() const{return true;} uint32 GetMapId()const{return 0;} uint32 GetInstanceId()const{return 0;} float GetPositionX()const{return x;} float GetPositionY()const{return y;} float GetPositionZ()const{return z;} };
float dist(Position a,Position b){return std::sqrt((a.x-b.x)*(a.x-b.x)+(a.y-b.y)*(a.y-b.y)+(a.z-b.z)*(a.z-b.z));}
struct ClientlessPathDiagnostics {bool Calculated=true, Complete=true,Suspicious=false; uint32 PathType=1,PointCount=2; float DirectDistance=0,PathLength=0,MaxSegmentLength=0,MaxZDelta=0,FinalDistance=0; std::string Reason;};
struct Travel {void AnalyzePath(Player* p,Position b,ClientlessPathDiagnostics& n){Position a;a.Relocate(p->x,p->y,p->z);n.PathLength=n.DirectDistance=dist(a,b);}} travel;
auto sClientlessTravelMgr=&travel;
struct Flight {
bool enabled=false;float minimumX=-10000;
void BeginPlanning(){}
bool Enabled(Player const*){return enabled;}
float Estimate(Player const*,uint32 map,Position a,Position b){
float d=dist(a,b);return enabled&&map==0&&a.x>=minimumX&&d>=60?180+d*1.5f:std::numeric_limits<float>::infinity();
}
} flight;
auto sClientlessFlightMgr=&flight;
struct VcorePositionPathResult {bool complete=false; uint32 Result=2;float FinalDistance=0,PathLength=0;bool IsComplete()const{return complete;}};
struct PathGenerator {static inline bool available=false; static inline float maxDistance=10000;static VcorePositionPathResult AnalyzePositions(uint32,uint32,uint32,Position a,Position b,uint32){float d=dist(a,b);return {available && d<=maxDistance,2,0,d};}};
std::string MakeLocalBridgeKey(std::string const&a,std::string const&b){return a+"->"+b;}
'''
cls='class ClientlessTravelGraphMgr { public: bool EnsureLoaded(){return true;}\n'+signature+'''
std::unordered_map<std::string,ClientlessTravelNode> _nodes;
std::unordered_map<std::string,std::vector<ClientlessTravelEdge>> _edges;
std::unordered_map<uint32,std::vector<ClientlessTravelEdgePoint>> _edgePoints;
};
'''
tests=r'''
void node(ClientlessTravelGraphMgr& g,std::string k,float x,float y=0){ClientlessTravelNode n;n.Key=k;n.X=x;n.Y=y;g._nodes[k]=n;}
void edge(ClientlessTravelGraphMgr&g,std::string a,std::string b,uint32 id,float cost,bool rev=false,std::string type="ground"){g._edges[a].push_back({id,a,b,type,cost,rev});}
std::vector<ClientlessTravelRouteStep> plan(ClientlessTravelGraphMgr&g,Player p,ClientlessTravelLocationGoal goal,float radius,std::unordered_set<std::string> blocked={}){
std::vector<ClientlessTravelRouteStep> r;bool complete=false;std::string reached,error;
assert(g.PlanAdaptiveRoute(&p,"B",radius,200,r,complete,reached,error,nullptr,&blocked,&goal));if(!complete){std::cerr<<"incomplete x="<<p.x<<" goal="<<goal.X<<" blocked="<<blocked.size()<<" error="<<error<<"\n";}assert(complete);return r;}
int main(){
ClientlessTravelGraphMgr g;node(g,"A",0);node(g,"B",1000);edge(g,"A","B",1,1000);
for(float x:{0.f,100.f,700.f,900.f,1000.f}){ClientlessTravelEdgePoint p;p.X=x;g._edgePoints[1].push_back(p);}
ClientlessTravelLocationGoal goal;goal.X=700;goal.Y=20;goal.AnchorRadius=50;
// A stored path exists. Its interior exit must still win without remote MMAP.
auto r=plan(g,{},goal,1);assert(r.back().GroundEndIndex==2);assert(r.back().X==700);
// Preserve a valid entry even when another entry reaches the endpoint cheaper.
Player p;p.x=100;r=plan(g,p,goal,900);assert(r.front().NodeKey=="virtual_ground_entry");assert(r.back().GroundStartIndex==1);assert(r.back().GroundEndIndex==2);
// Reverse direction uses execution-order indices.
ClientlessTravelGraphMgr rev=g;rev._edges.clear();edge(rev,"B","A",1,1000,true);p.x=900;goal.X=100;r=plan(rev,p,goal,1);assert(r.back().EdgeReverse);assert(r.back().GroundStartIndex==1);assert(r.back().GroundEndIndex==3);
// Blacklist the nearest failed exit: select the next eligible waypoint.
goal.X=700;goal.AnchorRadius=250;r=plan(g,{},goal,1,{"exit:1:2F"});assert(r.back().GroundEndIndex==3);
// Regular anchors use the same estimated local cost without remote MMAP.
goal.X=1000;goal.Y=0;goal.AnchorRadius=10;r=plan(g,{},goal,1);assert(r.back().NodeKey=="B");assert(r.back().GroundEndIndex==-1);
// Forward entry after a transport can exit on the same road before its endpoint.
ClientlessTravelGraphMgr f=g;node(f,"S",-1000);node(f,"T",100);edge(f,"S","T",2,1,false,"taxi");p.x=-1000;goal.X=700;goal.Y=20;goal.AnchorRadius=50;PathGenerator::available=true;PathGenerator::maxDistance=150;
r=plan(f,p,goal,1);assert(r.back().GroundStartIndex==1);assert(r.back().GroundEndIndex==2);assert(r[r.size()-2].NodeKey=="virtual_ground_entry");
// A regular node outside the exit radius must not win an optimistic local leg.
ClientlessTravelGraphMgr detour=g;node(detour,"N",300,0);edge(detour,"A","N",5,1,false,"taxi");goal.X=700;goal.Y=20;goal.AnchorRadius=1000;p.x=0;PathGenerator::available=false;
r=plan(detour,p,goal,1);assert(r.back().NodeKey=="virtual_ground_exit");
// Restore the directional test's narrow goal radius.
goal.AnchorRadius=50;
// A point beyond the exit cannot traverse the directed road backwards.
PathGenerator::available=false;p.x=900;r=plan(g,p,goal,1);assert(r.back().GroundStartIndex<0); // uses ordinary B anchor when expanded below
// New flight alternatives are transient and preserve the existing road preference.
flight.enabled=true;Player start;
ClientlessTravelGraphMgr air;node(air,"A",0);node(air,"B",1000);
std::vector<ClientlessTravelRouteStep> route;bool done=false;std::string endKey,why;
assert(air.PlanAdaptiveRoute(&start,"B",1,200,route,done,endKey,why));
assert(done&&route.size()==2&&route.back().EdgeType=="free_flight"&&route.back().EdgeDatabaseId==0);
assert(air._edges.empty()&&air._nodes.size()==2);
ClientlessTravelGraphMgr road=air;edge(road,"A","B",9,1000);
assert(road.PlanAdaptiveRoute(&start,"B",1,200,route,done,endKey,why));
assert(done&&route.back().EdgeType=="ground");
ClientlessTravelLocationGoal far;far.X=2000;far.AnchorRadius=50;
assert(air.PlanAdaptiveRoute(&start,"B",1,200,route,done,endKey,why,nullptr,nullptr,&far));
assert(done&&route.back().NodeKey=="flight_location"&&route.back().EdgeType=="free_flight");
// A permissible takeoff halfway along a real road may beat its far endpoint.
ClientlessTravelGraphMgr exits;node(exits,"A",0);node(exits,"B",1000);node(exits,"D",500,800);
edge(exits,"A","B",8,1000);for(float x:{0.f,100.f,500.f,900.f,1000.f}){ClientlessTravelEdgePoint pt;pt.X=x;exits._edgePoints[8].push_back(pt);}
flight.minimumX=400;
assert(exits.PlanAdaptiveRoute(&start,"D",1,200,route,done,endKey,why));
assert(done&&route.back().NodeKey=="D"&&route.back().EdgeType=="free_flight");
assert(route[route.size()-2].NodeKey=="virtual_ground_exit");
for(auto const& step:route)if(step.EdgeType=="free_flight")assert(step.NodeKey!="virtual_ground_entry");
std::cout<<"ground planner regressions and dynamic flight/node/location/virtual-exit integration passed\n";
}
'''
# Last direction case must be unreachable: explicitly inspect a partial result.
tests=tests.replace('r=plan(g,p,goal,1);assert(r.back().GroundStartIndex<0); // uses ordinary B anchor when expanded below','std::vector<ClientlessTravelRouteStep> partial;bool complete;std::string reached,error;assert(g.PlanAdaptiveRoute(&p,"B",1,200,partial,complete,reached,error,nullptr,nullptr,&goal));assert(!complete);')
with tempfile.TemporaryDirectory(prefix="vcore-flight-planner-") as folder:
cpp=Path(folder)/"test.cpp";binary=Path(folder)/"test"
cpp.write_text(preamble+structs+cls+method+tests)
subprocess.run(["c++","-std=c++17","-O0",str(cpp),"-o",str(binary)],check=True)
subprocess.run([str(binary)],check=True)
+145
View File
@@ -0,0 +1,145 @@
"""Compile the production reload/bindings with real Lua and deterministic server doubles."""
import pathlib
import subprocess
import tempfile
ROOT = pathlib.Path(__file__).resolve().parents[2]
SOURCE = (ROOT / "src/server/game/World/ClientlessLuaEngine.cpp").read_text()
def function(signature):
start = SOURCE.index(signature)
opening = SOURCE.index("{", start)
depth = 1
end = opening + 1
while depth:
depth += (SOURCE[end] == "{") - (SOURCE[end] == "}")
end += 1
return SOURCE[start:end]
HARNESS = r'''
#include <lua.hpp>
#include <cassert>
#include <cstdint>
#include <string>
#include <vector>
#include <unordered_map>
#include <utility>
using uint32 = uint32_t;
#define TC_LOG_INFO(...) ((void)0)
struct ClientlessIntent {
std::string Name;
std::unordered_map<std::string,std::string> Parameters;
bool Empty() const { return Name.empty(); }
};
using ClientlessAction = ClientlessIntent;
struct ClientlessAdventureProfile { int Revision = 0; };
struct Guid { uint32 GetCounter() const { return 26; } };
struct Player {
bool aboard = false, flying = false, teleport = false;
Guid GetGUID() { return {}; }
bool IsInWorld() { return true; }
bool GetTransport() { return aboard; }
bool IsInFlight() { return flying; }
bool IsBeingTeleported() { return teleport; }
};
enum class ClientlessActionExecutionState { Idle, SelectingTarget, Moving, Completed, Failed };
enum class ClientlessTravelState { Idle, Moving, Arrived };
struct ActionStatus { ClientlessActionExecutionState State = ClientlessActionExecutionState::Idle; };
struct TravelStatus { ClientlessTravelState State = ClientlessTravelState::Idle; };
struct Manager {
bool managed = true;
std::vector<ClientlessAction> actions;
ClientlessIntent intent;
bool IsManaged(uint32) { return managed; }
unsigned GetQueuedActionCount(uint32) { return actions.size(); }
bool QueueAction(uint32, ClientlessAction a) { actions.push_back(std::move(a)); return true; }
bool SetIntent(uint32, ClientlessIntent i) { intent = std::move(i); return true; }
bool ClearIntent(uint32) { intent = {}; return true; }
} manager;
struct Executor { ActionStatus status; ActionStatus* GetStatus(uint32) { return &status; } } executor;
struct Travel { TravelStatus status; TravelStatus* GetStatus(uint32) { return &status; } } travel;
auto* sClientlessPlayerMgr = &manager;
auto* sClientlessActionExecutorMgr = &executor;
auto* sClientlessTravelMgr = &travel;
class ClientlessLuaEngine {
public:
std::unordered_map<uint32,ClientlessAdventureProfile> _profiles;
std::string _scriptRoot = "/test";
std::string script;
bool profileOK = true;
bool LoadAdventureProfile(Player*) {
if (!profileOK) return false;
_profiles[26].Revision++;
return true;
}
bool ExecuteAdventureScript(Player*);
bool ReloadAdventureScript(Player*, std::string&);
};
'''
HARNESS += SOURCE[SOURCE.index("struct ReloadEffects"):SOURCE.index("uint32 BoundGuid")]
for signature in ["uint32 BoundGuid", "std::unordered_map<std::string, std::string> ReadMap",
"int LuaIntend", "int LuaClearIntent", "int LuaQueueAction"]:
HARNESS += function(signature) + "\n"
HARNESS += r'''
bool ClientlessLuaEngine::ExecuteAdventureScript(Player*) {
lua_State* L = luaL_newstate();
luaL_openlibs(L);
for (auto entry : {std::pair{"intend", LuaIntend}, {"clear_intent", LuaClearIntent}, {"queue_action", LuaQueueAction}}) {
lua_pushinteger(L,26); lua_pushcclosure(L,entry.second,1); lua_setglobal(L,entry.first);
}
bool success = luaL_loadstring(L, script.c_str()) == LUA_OK && lua_pcall(L,0,0,0) == LUA_OK;
lua_close(L);
return success;
}
'''
HARNESS += function("bool ClientlessLuaEngine::ReloadAdventureScript")
HARNESS += r'''
int main() {
Player p;
ClientlessLuaEngine engine;
std::string detail;
engine._profiles[26].Revision = 4;
manager.intent.Name = "old";
engine.script = "queue_action('move', {node='tram_if'}); intend('new'); error('failure')";
assert(!engine.ReloadAdventureScript(&p,detail));
assert(manager.actions.empty() && manager.intent.Name == "old");
assert(engine._profiles.at(26).Revision == 4 && !pendingReload);
engine.script = "this is invalid lua";
assert(!engine.ReloadAdventureScript(&p,detail));
assert(engine._profiles.at(26).Revision == 4 && manager.actions.empty());
engine.profileOK = false;
assert(!engine.ReloadAdventureScript(&p,detail));
engine.profileOK = true;
engine.script = "assert(not queue_action('')); queue_action('move', {node='tram_if'}); clear_intent(); intend('new')";
assert(engine.ReloadAdventureScript(&p,detail));
assert(manager.actions.size() == 1 && manager.actions[0].Parameters.at("node") == "tram_if");
assert(manager.intent.Name == "new" && engine._profiles.at(26).Revision == 5);
assert(!engine.ReloadAdventureScript(&p,detail)); // queued actions must not duplicate
manager.actions.clear();
executor.status.State = ClientlessActionExecutionState::Moving;
assert(!engine.ReloadAdventureScript(&p,detail));
executor.status.State = ClientlessActionExecutionState::Completed;
travel.status.State = ClientlessTravelState::Moving;
assert(!engine.ReloadAdventureScript(&p,detail));
travel.status.State = ClientlessTravelState::Arrived;
p.aboard = true; assert(!engine.ReloadAdventureScript(&p,detail)); p.aboard = false;
p.flying = true; assert(!engine.ReloadAdventureScript(&p,detail)); p.flying = false;
p.teleport = true; assert(!engine.ReloadAdventureScript(&p,detail)); p.teleport = false;
assert(!engine.ReloadAdventureScript(nullptr,detail));
manager.managed = false; assert(!engine.ReloadAdventureScript(&p,detail)); manager.managed = true;
engine._profiles.clear(); engine.script = "error('failure')";
assert(!engine.ReloadAdventureScript(&p,detail) && engine._profiles.empty());
engine.script = "clear_intent()";
assert(engine.ReloadAdventureScript(&p,detail) && manager.intent.Empty());
}
'''
with tempfile.TemporaryDirectory(prefix="vcore-lua-reload-") as folder:
source = pathlib.Path(folder) / "test.cpp"
binary = pathlib.Path(folder) / "test"
source.write_text(HARNESS)
flags = subprocess.check_output(["pkg-config", "--cflags", "--libs", "lua5.4"], text=True).split()
subprocess.run(["c++", "-std=c++17", str(source), "-o", str(binary), *flags], check=True)
subprocess.run([str(binary)], check=True)
print("Lua reload regression tests passed (production reload/bindings, real Lua 5.4).")
+83
View File
@@ -0,0 +1,83 @@
"""Exercise the actual portal handler/ACK/destination checks with Core doubles."""
from pathlib import Path
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[2]
source = (ROOT / "src/server/game/World/ClientlessActionExecutorMgr.cpp").read_text()
start = source.index(" WorldPacket areaTriggerPacket(CMSG_AREATRIGGER, 4);")
end = source.index(" record.Status.State = ClientlessActionExecutionState::Moving;", start)
fragment = source[start:end]
assert "same-map portal transition is not supported" not in source
harness = r'''
#include <cassert>
#include <cmath>
#include <string>
#define TC_LOG_WARN(...) ((void)0)
enum class ClientlessActionExecutionState { Idle, Failed, Moving };
constexpr int CMSG_AREATRIGGER=1;
struct WorldPacket { WorldPacket(int,int){} void operator<<(int){} };
struct Player;
struct Session {
Player* p;
int accepted=0,ackCount=0;
bool clientless=true,ackWorks=true;
void HandleAreaTriggerOpcode(WorldPacket&);
void CompleteClientlessTeleport();
bool IsClientless() { return clientless; }
};
struct Player {
int map=1; float x=8393,y=1003,z=30;
bool pending=false,world=true;
Session session{this};
bool IsBeingTeleported() { return pending; }
bool IsBeingTeleportedFar() { return pending&&map==0; }
bool IsInWorld() { return world; }
int GetMapId() { return map; }
float GetPositionX() { return x; } float GetPositionY() { return y; } float GetPositionZ() { return z; }
Session* GetSession() { return &session; }
};
struct Teleport { int target_mapId=1; float target_X=9946,target_Y=2612,target_Z=1316; } destination;
void Session::HandleAreaTriggerOpcode(WorldPacket&) { if (accepted) p->pending=true; }
void Session::CompleteClientlessTeleport() {
++ackCount;
if (!ackWorks) return;
p->pending=false;p->map=destination.target_mapId;
p->x=destination.target_X;p->y=destination.target_Y;p->z=destination.target_Z;
}
struct Record {
struct {ClientlessActionExecutionState State=ClientlessActionExecutionState::Idle; std::string Detail,TargetName;} Status;
bool Active=true;
} record;
struct {int EdgeDatabaseId=1;std::string NodeKey="portal_at_542_target";} step;
bool transfer(Player* player) {
int triggerId=542,characterGuid=26;
auto teleport=&destination;
'''
harness += fragment
harness += r'''
return true;
}
int main() {
Player denied;
assert(!transfer(&denied)&&!record.Active&&record.Status.State==ClientlessActionExecutionState::Failed);
Player near;near.session.accepted=1;record={};
assert(transfer(&near)&&near.session.ackCount==1&&near.map==1&&near.x==9946&&!near.pending);
Player far;far.map=0;far.session.accepted=1;record={};
assert(transfer(&far)&&far.session.ackCount==1&&far.map==1&&!far.pending);
Player pending;pending.session.accepted=1;pending.session.ackWorks=false;record={};
assert(!transfer(&pending)&&pending.pending&&!record.Active);
Player network;network.session.accepted=1;network.session.clientless=false;record={};
assert(!transfer(&network)&&!record.Active);
Player wrongMap;wrongMap.map=0;record={};assert(!transfer(&wrongMap));
Player alreadyAtTarget;alreadyAtTarget.x=destination.target_X;alreadyAtTarget.y=destination.target_Y;alreadyAtTarget.z=destination.target_Z;
record={};assert(transfer(&alreadyAtTarget)); // destination reached independently is valid
}
'''
with tempfile.TemporaryDirectory(prefix="vcore-portal-test-") as folder:
cpp, binary = Path(folder) / "test.cpp", Path(folder) / "test"
cpp.write_text(harness)
subprocess.run(["c++", "-std=c++17", str(cpp), "-o", str(binary)], check=True)
subprocess.run([str(binary)], check=True)
print("Portal near/far ACK and denied same-map transfer regressions passed.")
+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.')
@@ -0,0 +1,23 @@
"""Execute production TaxiImport UPDATE statements against enabled/disabled rows."""
from pathlib import Path
import ast,re,sqlite3
source=(Path(__file__).resolve().parents[2]/'src/server/scripts/Custom/viano_bot_commands.cpp').read_text()
source=source[source.index('static bool HandleClientlessTaxiImport'):source.index('static bool HandleClientlessPortalImport')]
statements=[]
for group in re.findall(r'WorldDatabase\.DirectPExecute\(\s*((?:"[^"\n]*"\s*)+),',source):
query=''.join(ast.literal_eval(s) for s in re.findall(r'"[^"\n]*"',group))
if query.startswith('UPDATE '): statements.append(query)
assert len(statements)==2
for enabled in (0,1):
db=sqlite3.connect(':memory:')
db.create_function('IF',3,lambda condition,yes,no:yes if condition else no)
db.executescript('CREATE TABLE vcore_clientless_world_point (point_key TEXT,category TEXT,map INT,position_x REAL,position_y REAL,position_z REAL,orientation REAL,enabled INT,comment TEXT); CREATE TABLE vcore_clientless_travel_node (node_key TEXT,node_type TEXT,world_point_key TEXT,enabled INT,comment TEXT);')
db.execute('INSERT INTO vcore_clientless_world_point VALUES (?,?,?,?,?,?,?,?,?)',('taxi_585','travel_taxi',1,0,0,0,0,enabled,'manual state'))
db.execute('INSERT INTO vcore_clientless_travel_node VALUES (?,?,?,?,?)',('taxi_585','taxi','taxi_585',enabled,'manual state'))
db.execute(statements[0]%(1,8334.76,1029.83,22.7917,585,'taxi_585'))
db.execute(statements[1]%('taxi_585',585,'taxi_585'))
for table in ('vcore_clientless_world_point','vcore_clientless_travel_node'):
flag,comment=db.execute('SELECT enabled,comment FROM '+table).fetchone()
assert flag==enabled
assert comment.startswith('Auto-imported') if enabled else comment=='manual state'
print('Taxi importer preserves disabled rows and their reasons.')
+214
View File
@@ -0,0 +1,214 @@
#!/usr/bin/env python3
"""Exercise production dock-state/recorder/executor methods with deterministic world doubles.
Run: python3 tests/clientless/test_transport.py
Requires g++ and Python; does not connect to a server or database. Real collision,
network visibility and map-transfer integration still require server tests.
"""
from pathlib import Path
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[2]
def function(text, marker):
start = text.index(marker)
opening = text.index('{', start)
depth = 1
end = opening + 1
while depth:
depth += (text[end] == '{') - (text[end] == '}')
end += 1
return text[start:end]
source = (ROOT / 'src/server/game/World/ClientlessTransportMgr.cpp').read_text()
header = (ROOT / 'src/server/game/World/ClientlessTransportMgr.h').read_text()
header = '\n'.join(line for line in header.splitlines() if not line.startswith('#'))
header = header.replace('private:', 'public:').replace('TC_GAME_API ', '')
core = (ROOT / 'src/server/game/Entities/GameObject/GameObject.cpp').read_text()
base = (ROOT / 'src/server/game/Entities/Vehicle/VehicleDefines.h').read_text()
position = function(base, ' static void CalculatePassengerPosition(')
offset = function(base, ' static void CalculatePassengerOffset(')
preamble = r'''
#include <algorithm>
#include <cassert>
#include <cmath>
#include <cstdint>
#include <iostream>
#include <limits>
#include <map>
#include <string>
#include <unordered_map>
#include <vector>
using uint8=uint8_t; using uint32=uint32_t; using uint64=uint64_t; using int32=int32_t;
#define TC_LOG_INFO(...) do {} while(0)
#define TC_LOG_WARN(...) do {} while(0)
#define TC_LOG_ERROR(...) do {} while(0)
struct ObjectGuid {uint32 id=0;uint32 GetCounter() const{return id;}bool operator==(ObjectGuid b)const{return id==b.id;}bool operator!=(ObjectGuid b)const{return id!=b.id;}};
struct Position {
float x=0,y=0,z=0,o=0;
static float NormalizeOrientation(float a){a=std::fmod(a,float(2*M_PI));return a<0?a+2*M_PI:a;}
void Relocate(float a,float b,float c,float d=0){x=a;y=b;z=c;o=d;}
void Relocate(Position const& p){*this=p;}
Position const& GetPosition()const{return *this;}
void GetPosition(float&a,float&b,float&c,float&d)const{a=x;b=y;c=z;d=o;}
float GetPositionX()const{return x;}float GetPositionY()const{return y;}float GetPositionZ()const{return z;}float GetOrientation()const{return o;}
float GetExactDist(Position const*p)const{return std::sqrt((x-p->x)*(x-p->x)+(y-p->y)*(y-p->y)+(z-p->z)*(z-p->z));}
};
constexpr int MOVE_RUN=0;
struct TransportDockState {uint32 Progress=0,Period=0,RemainingPause=0;bool Stopped=false;};
class Player; class GameObject;
class TransportBase {
public:
GameObject* go=nullptr;
void AddPassenger(Player*);void RemovePassenger(Player*);
void CalculatePassengerPosition(float&,float&,float&,float*)const;
void CalculatePassengerOffset(float&,float&,float&,float*)const;
'''
world = r'''
};
class GameObject:public Position {
public:
TransportBase base;uint32 map=0,entry=1,spawn=1;TransportDockState state{0,30000,10000,true};
GameObject(){base.go=this;}
bool IsInWorld()const{return true;}uint32 GetMapId()const{return map;}
ObjectGuid GetGUID()const{return {spawn};}
TransportBase* ToTransportBase(){return &base;}
bool GetTransportDockState(TransportDockState&s)const{s=state;return true;}
uint32 deadline=0; uint32 GetUInt32Value(int)const{return deadline;}
};
struct Session {int completed=0;void CompleteClientlessTeleport() {++completed;}} session;
struct Motion {Player*p; void MoveSmoothPath(int,Position* path,int,bool);};
class Player:public Position {
public:
TransportBase* transport=nullptr;uint32 map=0;GameObject* object=nullptr;Motion motion{this};bool pending=false;
struct {struct {Position pos;ObjectGuid guid;uint32 time=0;void Reset(){pos={};guid={};time=0;}}transport;}m_movementInfo;
bool IsAlive()const{return true;}bool IsFlying()const{return false;}bool IsInFlight()const{return false;}
bool IsInWorld()const{return true;}bool IsInPhase(GameObject*)const{return true;}
bool IsBeingTeleported()const{return pending;}TransportBase* GetTransport()const{return transport;}
ObjectGuid GetGUID()const{return {26};}uint32 GetMapId()const{return map;}
float GetSpeed(int)const{return 7;}void StopMoving(){}bool IsSplineEnabled()const{return false;}
Motion* GetMotionMaster(){return &motion;}Session* GetSession(){return &session;}
};
void TransportBase::CalculatePassengerPosition(float&x,float&y,float&z,float*o)const{CalculatePassengerPosition(x,y,z,o,go->x,go->y,go->z,go->o);}
void TransportBase::CalculatePassengerOffset(float&x,float&y,float&z,float*o)const{CalculatePassengerOffset(x,y,z,o,go->x,go->y,go->z,go->o);}
void TransportBase::AddPassenger(Player*p){p->transport=this;p->m_movementInfo.transport.guid=go->GetGUID();}
void TransportBase::RemovePassenger(Player*p){p->transport=nullptr;p->m_movementInfo.transport.Reset();}
void Motion::MoveSmoothPath(int,Position* path,int,bool){p->Relocate(path[1]);if(p->transport){Position pos=*p;p->transport->CalculatePassengerOffset(pos.x,pos.y,pos.z,&pos.o);p->m_movementInfo.transport.pos=pos;}}
struct Travel {void CancelTravel(uint32){}}travel;
auto sClientlessTravelMgr=&travel;
// Model the real Field API's strict debug type checks for SMALLINT versus INT.
struct Field {
bool small=false;uint32 number=0;float real=0;
uint32 GetUInt32()const{return small?0:number;}
uint16_t GetUInt16()const{return small?uint16_t(number):0;}
float GetFloat()const{return real;}
};
struct Result {Field fields[5]={{true,369,0},{false,0,1},{false,0,2},{false,0,3},{false,0,0}};Field* Fetch(){return fields;}};
using QueryResult=Result*;
struct Database {Result result;template<class...Args>QueryResult PQuery(char const*,Args...){return &result;}} WorldDatabase;
bool Safe(std::string const& key){return !key.empty();}
namespace Trinity {bool IsValidMapCoord(float,float,float,float=0){return true;}}
'''
helpers = '\n'.join(function(source, marker) for marker in [
' float Distance(', ' bool SameService(', ' bool LoadNode(', ' Position WorldPoint(', ' float PathLength('
])
methods = '\n'.join(function(source, marker) for marker in [
'bool ClientlessTransportMgr::HasConnection(', 'bool ClientlessTransportMgr::AtDock(',
'bool ClientlessTransportMgr::Sample(', 'bool ClientlessTransportMgr::Start(',
'void ClientlessTransportMgr::SetPhase(', 'void ClientlessTransportMgr::Cancel(',
'void ClientlessTransportMgr::UpdateRide('
])
stubs = r'''
void PublishPassengerState(Player*){}
bool ClientlessTransportMgr::EnsureLoaded(){return true;}
GameObject* ClientlessTransportMgr::FindObject(Player*p,ClientlessTransportStop const&s)const{return p->object && p->object->entry==s.Entry && p->object->spawn==s.Spawn ? p->object : nullptr;}
'''
animated = r'''
namespace GameTime {uint32 now=0;uint32 GetGameTimeMS(){return now;}}
constexpr int GAMEOBJECT_LEVEL=0;
struct TransportAnimationEntry {uint32 TimeIndex=0;struct{float X=0,Y=0,Z=0;}Pos;};
struct TransportRotationEntry {float X=0,Y=0,Z=0,W=1;};
struct TransportAnimation {uint32 TotalTime=30033;std::map<uint32,TransportAnimationEntry const*>Path;std::map<uint32,TransportRotationEntry const*>Rotations;};
struct Animated {
GameObject _owner;TransportAnimation* _animationInfo=nullptr;uint32 _pathProgress=0;std::vector<uint32>_stopFrames;
'''+function(core, ' bool GetTransportDockState(TransportDockState& state) const override').replace(' override','')+'\n};\n'
tests = r'''
ClientlessTransportPoint point(bool on,float x){ClientlessTransportPoint p;p.OnTransport=on;p.Pos.x=x;return p;}
ClientlessTransportStop stop(std::string node,float dock,float land){ClientlessTransportStop s;s.Node=node;s.Entry=1;s.Spawn=1;s.Map=0;s.Period=30000;s.Dock.x=dock;s.Points={point(false,land),point(false,land>dock?land-1:land+1),point(true,1)};return s;}
using Phase=ClientlessTransportPhase;
void run(ClientlessTransportMgr&m,Player&p,ClientlessTransportMgr::Ride&r,int n=20){for(int i=0;i<n;i++)m.UpdateRide(&p,r,100);}
int main(){
// Regression: a real-world-point SMALLINT map must survive loading as 369.
uint32 loadedMap=0;Position loadedPosition;assert(LoadNode("tram_sw",loadedMap,loadedPosition));assert(loadedMap==369);assert(loadedPosition.x==1&&loadedPosition.y==2&&loadedPosition.z==3);
// Actual animation-state method: no state change is required for an animation pause.
TransportAnimationEntry a{0,{0,0,0}},b{5000,{0,0,0}},c{15000,{0,0,-60}},d{20000,{0,0,-60}},e{30033,{0,0,0}};
TransportAnimation anim;anim.Path={{0,&a},{5000,&b},{15000,&c},{20000,&d},{30033,&e}};
Animated animated;animated._animationInfo=&anim;TransportDockState snapshot;
animated._pathProgress=1000;assert(animated.GetTransportDockState(snapshot));assert(snapshot.Stopped&&snapshot.RemainingPause==4000);
animated._pathProgress=5000;animated.GetTransportDockState(snapshot);assert(!snapshot.Stopped);
animated._pathProgress=17000;animated.GetTransportDockState(snapshot);assert(snapshot.Stopped&&snapshot.RemainingPause==3000);
TransportRotationEntry r0,r1;r1.Z=1;anim.Rotations={{0,&r0},{30033,&r1}};animated.GetTransportDockState(snapshot);assert(!snapshot.Stopped);
anim.Rotations.clear();animated._stopFrames={1000};animated._owner.deadline=2000;GameTime::now=1000;animated.GetTransportDockState(snapshot);assert(!snapshot.Stopped);
GameTime::now=2000;animated.GetTransportDockState(snapshot);assert(snapshot.Stopped&&snapshot.RemainingPause==std::numeric_limits<uint32>::max());
// Round-trip the actual core transform for a rotated transport.
float x=3,y=4,z=5,o=0.7f;TransportBase::CalculatePassengerPosition(x,y,z,&o,100,200,300,1.5708f);TransportBase::CalculatePassengerOffset(x,y,z,&o,100,200,300,1.5708f);
assert(std::fabs(x-3)<0.01f&&std::fabs(y-4)<0.01f&&std::fabs(z-5)<0.01f&&std::fabs(o-0.7f)<0.01f);
ClientlessTransportMgr mgr;mgr._stops["A"]=stop("A",5,0);mgr._stops["B"]=stop("B",100,104);
assert(mgr.HasConnection("A","B"));mgr._stops["B"].Spawn=2;assert(!mgr.HasConnection("A","B"));mgr._stops["B"].Spawn=1;
Player p;GameObject go;go.x=5;p.object=&go;std::string error;
assert(mgr.Start(&p,"A","B",error));auto&r=mgr._rides.at(26);
go.state.RemainingPause=100;run(mgr,p,r);assert(r.Status.Phase==Phase::Waiting&&!p.transport);
go.state.RemainingPause=10000;run(mgr,p,r);assert(r.Status.Phase==Phase::Riding&&p.transport==&go.base);assert(std::fabs(p.m_movementInfo.transport.pos.x-1)<0.01);
// Wrong location despite a stopped object must not allow disembarkation.
go.x=50;p.x=51;run(mgr,p,r);assert(r.Status.Phase==Phase::Riding&&p.transport);
go.x=100;p.x=101;run(mgr,p,r);assert(r.Status.Phase==Phase::Arrived&&!p.transport&&p.x==104);
// Cancellation on land does not attach a passenger.
p.x=0;go.x=5;assert(mgr.Start(&p,"A","B",error));mgr.Cancel(26);auto&cancel=mgr._rides.at(26);run(mgr,p,cancel);assert(cancel.Status.Phase==Phase::Failed&&cancel.Status.Cancelled&&!p.transport);
// Midride cancellation retains the Passenger until a recorded safe dock.
p.x=0;assert(mgr.Start(&p,"A","B",error));auto&abort=mgr._rides.at(26);run(mgr,p,abort);assert(p.transport);
go.x=50;p.x=51;mgr.Cancel(26);run(mgr,p,abort);assert(abort.Status.Phase==Phase::Riding&&p.transport);
go.x=5;p.x=6;run(mgr,p,abort);assert(abort.Status.Phase==Phase::Failed&&abort.Status.Cancelled&&!p.transport&&p.x==0);
// A timeout aboard never removes the passenger over sea/in midair.
assert(mgr.Start(&p,"A","B",error));auto&timeout=mgr._rides.at(26);run(mgr,p,timeout);assert(p.transport);timeout.Elapsed=1800001;run(mgr,p,timeout,1);assert(timeout.Status.Phase==Phase::Failed&&p.transport);go.base.RemovePassenger(&p);
// Departure during a boarding transition keeps the Passenger until safe recovery.
p.x=0;go.x=5;go.state.Stopped=true;assert(mgr.Start(&p,"A","B",error));auto&late=mgr._rides.at(26);
for(int i=0;i<10&&!p.transport;i++)mgr.UpdateRide(&p,late,100);
assert(p.transport);go.state.Stopped=false;mgr.UpdateRide(&p,late,100);assert(late.Status.Phase==Phase::Riding&&p.transport);
go.state.Stopped=true;run(mgr,p,late);assert(late.Status.Phase==Phase::Failed&&!p.transport);
// Recorder captures the coordinate-frame switch and rejects leaving the vessel.
p.x=0;ClientlessTransportMgr::Recording rec;rec.Stop=mgr._stops.at("A");rec.Stop.Points={point(false,0)};rec.Object=go.GetGUID();
p.x=1;assert(mgr.Sample(&p,rec,true));p.x=6;go.base.AddPassenger(&p);assert(mgr.Sample(&p,rec,true));assert(rec.Stop.Points.back().OnTransport&&std::fabs(rec.Stop.Points.back().Pos.x-1)<0.01);
go.base.RemovePassenger(&p);assert(!mgr.Sample(&p,rec,true));assert(!rec.Error.empty());
// Boarding onto the wrong vehicle and position jumps do not produce recordings.
ClientlessTransportMgr::Recording jump;jump.Stop=mgr._stops.at("A");jump.Stop.Points={point(false,0)};jump.Object=go.GetGUID();p.x=20;assert(!mgr.Sample(&p,jump,true));
// Native ships switch between per-map copies of the same spawn. The executor
// must resolve the new copy, preserve the ride and leave only at the target.
ClientlessTransportMgr ships;
ships._stops["harbor_a"]=stop("harbor_a",5,0);ships._stops["harbor_a"].Map=1;
ships._stops["harbor_b"]=stop("harbor_b",100,104);ships._stops["harbor_b"].Map=0;
Player sailor;GameObject departure;departure.x=5;departure.map=1;sailor.map=1;sailor.object=&departure;
assert(ships.Start(&sailor,"harbor_a","harbor_b",error));auto& crossing=ships._rides.at(26);
run(ships,sailor,crossing);assert(crossing.Status.Phase==Phase::Riding&&sailor.transport==&departure.base);
sailor.pending=true;sailor.object=nullptr;
ships.UpdateRide(&sailor,crossing,100);
assert(session.completed==1&&crossing.Status.Phase==Phase::Riding); // do not fail during ACK
// Model the native worldport ACK's transfer to the destination-map copy.
GameObject destination;destination.x=50;destination.map=0;
sailor.pending=false;departure.base.RemovePassenger(&sailor);sailor.map=0;sailor.object=&destination;
destination.base.AddPassenger(&sailor);sailor.x=51;
run(ships,sailor,crossing);assert(crossing.Status.Phase==Phase::Riding&&sailor.transport==&destination.base);
destination.x=100;sailor.x=101;
run(ships,sailor,crossing);assert(crossing.Status.Phase==Phase::Arrived&&!sailor.transport&&sailor.x==104&&sailor.map==0);
std::cout<<"Transport dock, frame, recorder, cross-map copy and ride regressions passed\n";
}
'''
with tempfile.TemporaryDirectory(prefix='vcore-transport-test-') as directory:
path = Path(directory)
cpp = path / 'transport.cpp'
cpp.write_text(preamble + position + '\n' + offset + world + header + helpers + stubs + methods + animated + tests)
subprocess.run(['g++', '-std=c++17', '-O0', '-g', '-Wall', '-Wextra', '-Wno-unused-parameter', str(cpp), '-o', str(path / 'test')], check=True)
subprocess.run([str(path / 'test')], check=True)
@@ -0,0 +1,110 @@
"""Exercise actual core grid initialization without bots or a world database."""
from pathlib import Path
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[2]
def block(source, signature):
start = source.index(signature)
end = source.index("{", start) + 1
depth = 1
while depth:
depth += (source[end] == "{") - (source[end] == "}")
end += 1
return source[start:end]
maps = (ROOT / "src/server/game/Maps/Map.cpp").read_text()
objects = (ROOT / "src/server/game/Globals/ObjectMgr.cpp").read_text()
manager = (ROOT / "src/server/game/Maps/MapManager.cpp").read_text()
registration = block(block(objects, "void ObjectMgr::LoadGameObjects()"), "if (gameEvent == 0)")
for function in ("Map* MapManager::CreateWorldMap", "InstanceMap* MapManager::CreateInstance",
"BattlegroundMap* MapManager::CreateBattleground"):
assert "map->LoadTransportGameObjectGrids();" in block(manager, function)
assert manager.index("map->LoadRespawnTimes();") < manager.index("map->LoadTransportGameObjectGrids();")
harness = r'''
#include <cassert>
#include <cstdint>
#include <unordered_map>
#include <vector>
#include <set>
#include <utility>
using uint32 = uint32_t;
struct ObjectGuid { using LowType = uint32; };
#define TC_LOG_INFO(...) ((void)0)
constexpr int GAMEOBJECT_TYPE_TRANSPORT = 11;
struct Position {
float x,y;
float GetPositionX() const { return x; }
float GetPositionY() const { return y; }
};
struct GameObjectData { uint32 mapId,spawnMask; Position spawnPoint; };
struct Template { int type; };
struct ObjectManager {
std::unordered_map<uint32,std::vector<uint32>> _transportGameObjectSpawns;
std::unordered_map<uint32,GameObjectData> data;
unsigned ordinaryGridRegistrations = 0;
void AddGameobjectToGrid(uint32,GameObjectData*) { ++ordinaryGridRegistrations; }
void Register(int gameEvent, uint32 guid, GameObjectData data, Template const* gInfo) {
'''
harness += registration
harness += r'''
}
auto GetTransportGameObjectSpawns(uint32 id) const -> std::vector<uint32> const* {
auto itr = _transportGameObjectSpawns.find(id);
return itr==_transportGameObjectSpawns.end() ? nullptr : &itr->second;
}
auto GetGameObjectData(uint32 id) const -> GameObjectData const* {
auto itr=data.find(id); return itr==data.end() ? nullptr : &itr->second;
}
} objects;
auto* sObjectMgr = &objects;
struct Cell { float x,y; Cell(float a,float b):x(a),y(b){} };
class Map {
public:
uint32 id=369, mode=0;
std::set<std::pair<int,int>> loaded;
uint32 GetId() { return id; }
uint32 GetInstanceId() { return 0; }
uint32 GetSpawnMode() { return mode; }
bool EnsureGridLoaded(Cell c) { return loaded.emplace(int(c.x/533),int(c.y/533)).second; }
void LoadTransportGameObjectGrids();
};
'''
harness += block(maps, "void Map::LoadTransportGameObjectGrids()")
harness += r'''
int main() {
Template tram{11}, ship{15}, chest{3};
GameObjectData sw{369,1,{4,2490}}, ironforge{369,1,{4,8}};
objects.Register(0,18803,ironforge,&tram);
objects.Register(0,18804,sw,&tram);
objects.Register(7,1,sw,&tram); // inactive/active events retain their own lifecycle
objects.Register(-7,2,sw,&tram);
objects.Register(0,3,sw,&ship); // separate native moving-transport loader
objects.Register(0,4,sw,&chest);
assert(objects.ordinaryGridRegistrations==4);
assert(objects.GetTransportGameObjectSpawns(369)->size()==2);
objects.data.emplace(18803,ironforge); objects.data.emplace(18804,sw);
Map map;
// Both station grids must load without any player, bot or transport node.
map.LoadTransportGameObjectGrids();
assert(map.loaded.size()==2);
map.LoadTransportGameObjectGrids(); assert(map.loaded.size()==2);
Map unrelated; unrelated.id=0; unrelated.LoadTransportGameObjectGrids();
assert(unrelated.loaded.empty());
Map differentDifficulty; differentDifficulty.mode=1;
differentDifficulty.LoadTransportGameObjectGrids(); assert(differentDifficulty.loaded.empty());
objects.data.erase(18803); // removed spawn indexed earlier must be skipped
objects.data[18804].mapId=0; // likewise a moved spawn
Map removed; removed.LoadTransportGameObjectGrids(); assert(removed.loaded.empty());
}
'''
with tempfile.TemporaryDirectory(prefix="vcore-grid-transport-") as folder:
source, executable = Path(folder) / "test.cpp", Path(folder) / "test"
source.write_text(harness)
subprocess.run(["c++", "-std=c++17", str(source), "-o", str(executable)], check=True)
subprocess.run([str(executable)], check=True)
print("Core transport grid initialization regressions passed (no CPC dependency).")