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