Improve AdventureBot planning and travel performance

This commit is contained in:
Nico committed 2026-10-31 15:35:46 +01:00
1 parent 69b5a1c4a1
commit d1ef007cef
18 files changed
+906 -27

No files matched your search

+112
View File
@@ -1,3 +1,115 @@
# Aktuell: Routenplaner-Last behoben und volle Population live geprüft
Nutzer meldete Serverlags und bat nach Prüfung bestehender Navigationsprobleme
um den Fix. Vorher mit110Bots: ActionExecutor regelmäßig20–29s je30s, einzelne
PlanAdaptiveRoute-Aufrufe bis10.9s/13693FlightEstimates. Ursache war eine implizit
nahezu vollständige Flugverknüpfung, Flugausstiegprüfung jedes Straßenpunktes,
virtuelle Straßeneinstiegssuche auch an normalen Graphknoten und insbesondere
40-Yard-Gebietssampling ganzer nativer Taxipfade für neue Level/Fraktionskeys.
Fix in ClientlessTravelGraphMgr: maximal2 vorsortierte Flugausstiege pro Kante/
8proPlan,16 echte FlightEstimates proPlan, Schätzcache über beide Suchtier,
Flugconnectoren nur Ziel/mapwechselnde oder physische Transportquellen statt
aller Taxis, direkter same-map Location-Flug vor Makrosuche, virtuelle Forward-
Entry-Reparatur nur an echten Graph-Sackgassen. AdventureAreaMgr prüft native
Taxipfadexistenz plus bereits fraktions-/gebietskataloggeprüfte Endknoten; das
Überfliegen gesperrter Gebiete ist erlaubt und wird nicht mehr in40yd-Schritten
abgetastet. Native Flug-/Gebiets-/Start-/Landesicherheit bleibt bestehen.
Mehrere inkrementelle Builds regelkonform: Loader/Testaufnahme0, safe logout,
nullSitzungen oder dokumentierte5min-Ausnahme, saubererShutdown, ausschließlich
-j10. Gezielter test_free_flight_planner bei jedem Build PASS, diff-check PASS.
Final installiert; frischer ready-Prozess47340, Ports8085/8087. Vier Recoverybots
54/64/89/109 in Fraktionshauptstädten geprüft; Details unter
doc/clientless/checkpoints/2026-10-31-route-performance/. Loader/Testaufnahme
wieder1, volle Pools50/5/50/5=110 online.
Live nach finalem Fix: Kaltplanung statt10–20s noch1.93s einmalig; folgende
vergleichbare Routen51–153ms. Bei110Bots drei volle30s-Fenster Actions4.11/4.38/
4.14s statt20–29s; Controllers6.63/7.06/6.79s, keine RECOVERY_FAILED oder
TRAVEL_PLAN_FAILED. CPU-Prozess etwa130% über mehrere Threads; Server bleibt
ready. Noch auffällig: Fizzira43 wiederholt alle2–3s denselben Intent-Fallback
instance_visit_at_2217->446 (Intent none, keine Action), kein eigentlicher
Travelplan-Fehler. Andere neue Fallbacks:154(13),95(12),50/114(8),146(7),105(6).
Fizzira ist ein separater Tagesplan/Intent-Fallback-Bug und sollte als nächstes
behoben werden. Kein Commit/Push für diese Arbeit angefordert.
---
# Aktuell: Brindras verwaister Gruppen-Pfadvalidator behoben und live geprüft
Brindra86 hing nach einer früheren Gruppe hinter der Tiefenbahn auf der
Eisenschmiede-Seite fest. MMAP berechnete vollständige Wege, doch TravelMgr
meldete `owner ground-safety validator rejected spline`; die Route startete
wiederholt über `virtual_ground_entry`. Ursache: `AdventureGroupTravelMgr::Observe`
kehrte bei `GetGroup()==nullptr` zurück, ohne den zuvor installierten
GroundPathAdapter/PathValidator und Gruppenreise-Record zu entfernen.
Fix in AdventureGroupTravelMgr.cpp: Wenn keine Gruppe mehr besteht und ein
Gruppenreise-Record vorhanden ist, wird `Clear(player)` aufgerufen. Dadurch werden
Adapter, Validator, eigener Reisezustand und Record entfernt; Bots ohne früheren
Record bleiben unberührt. Regression in test_adventure_group_travel.py verfolgt
beide Registrierungen und beweist deren Entfernung samt Record/Reiseabbruch.
Buildregel eingehalten: Loader/Testaufnahme0, safe logout angefordert. Nach fünf
Minuten blieben13 Bots auf regulären Flug/Taxistrecken; Saveall, vollständige TSV-
Liste mit GUID/Name/Fraktion/Position/Taxi und native Info/Flight/Transport/Action-
Ausgaben unter `/home/wow_admin/wow_cata_server/checkpoints/brindra-group-validator/`.
Server sauber beendet. Gruppentravel-TestPASS. Build-j10 am Ende vollständig
gelinkt (ELF geprüft), installiert, frischer ready-Start Prozess32369, Ports8085/
8087. Wegen einer fehlerhaften Tool-Sitzungsrückmeldung liefen kurz mehrere eigene
Linkprozesse auf dasselbe Artefakt; ausschließlich diese Prozesse beendet, danach
ein einzelner sauberer Neu-Link. Installierte alte Binary war dabei nie überschrieben.
Alle13 Restbots eingeloggt, alte Flug-/Transportzustände waren weg, in
Sturmwind/Orgrimmar geborgen und per Area-Ausgabe bestätigt. Loader/Testaufnahme
wieder1; tatsächlich enabled1/schema1/busy0/known110. Brindra war erneut in einer
Gruppe; `interaction 86 leave` führte live zu `No group travel state`, grouped0.
Der zuvor abgelehnte Weg läuft nun mit Travel=Moving und Nav reason=`ok`, ohne
PathRejected. Brindras Schedule ist wieder an. Kein Commit/Push angefordert.
---
# Aktuell: GM-Tagesplaneditor implementiert, gebaut, installiert und live geprüft
Nutzerauftrag: aktuelle/künftige Intents samtLocation perBefehl, GUID/Population,
Fraktions- undLevelgruppenfilter. NeuerBefehl `clientless adventure editplan`
unterbestehendemGM-Kommando. Syntax/Beispiele: doc/clientless/plan-commands.md.
Scope ausdrücklich EINGELOGGTE AdventureBots, heutige aktuelle/künftigeStunden.
GUID|all, current|next|next+N|Stundenrange, Intent|keep|instance-or-raid,
Location|auto|capital|keep, alliance|horde|both, Min/Max1..85, preview|apply.
Vorschau kopiertRecord und verändert wederDB nochRNG. Änderungen validiert mit
normalemAllowed/Level/Area/Flight; FormationbenötigtzulässigenFolgeblock, nieStunde23.
GültigeBots persistieren sofort und aktualisierenRAMPlan; ungültigeBots werden
mitGrund proBot komplett übersprungen. Aktuell benutzt sicherenSetWantedHandover,
zukünftig lässtaktuellenIntentlaufen. KeineTeleports/Gruppenauflösung durchEditor.
KeineSchemaänderung/keineLuaänderung nötig. Current reaktiviertSchedule, Future
behältmanuellenSchedulemode; beiBedarf schedule GUID on.
Betrieb: User warerneutbeimAufzeichnen, asynchrongefragt; Antwort "bin fertig gut
aufgepasst" erlaubteInstallation/Restart. Loader/TestAdmissionpausiert, safeall um
13:41:55CET; nachmehrals5min9Restbots53/54/67/74/75/86/89/125/145 mitSnapshot,
saveallDBPositionen und verfügbarenFlags dokumentiert; sauberShutdown10, Prozessende
verifiziertVORBuild. BestehenderNinjaDebugBuild-j10 EXIT0, viergezielteTestsPASS.
InstallEXIT0, frischerreadyStartProzess25737, Ports8085/8087geprüft. NeunBots
geprüft/indenHauptstädtenzurückgesetzt. Brindra86brauchtezweitenTeleport nachsicherem
BehaviorStop; area5148bestätigt undSchedulewiederon. Loader/TestEnabled1undAdmission
restauriert; tatsächlichenLoader enabled1/schema1/busy0/pools50/5/50/5bestätigt.
Restbotpositionsquelle ausdrücklich letztesSaveall, keineunbelegtenLivekoordinaten.
NativeLiveprüfung: Preview26next undallianceLevel60Filter, dann26Stunde20
social->rest(auto), DBpersistenzgeprüft; originalsocial/goldshire_tavern wiedermit
Befehlhergestellt und alleSlots18..23gegenBackupidentischbestätigt. Dergeplante
Gruppentest14–15/15–16bleibtbestehen. RaidJoin0/BGJoin0unverändert.
KeinCommitfürdiesenAuftragangefordert/erstellt, vorherigerCommit69b5a1c4a1.
Code: AdventurePlanCommand.h, AdventureBehaviorMgr.cpp/.h. Test:
tests/clientless/test_adventure_plan_commands.py extrahiertproduktiveEditPlanMethode.
Belege: doc/clientless/checkpoints/2026-10-31-plan-command/. VierTestsstattkompletter
Suiteerneut, frühere120/120historisch. git diff --check erfolgreich.
Vorheriges32MBRohlogundPythonCacheweiterlokalunversioniert, nichtlöschen.
---
# Aktueller Gruppentest geplant: 31.10., 14–15 Uhr Vorbereitung, 15–16 Uhr Ziele
Nutzer verlangt NÄCHSTEN Intent Gruppenbildung, danach gemischt Dungeon/Raid.
@@ -0,0 +1,13 @@
{
"cause": "group travel path validator remained registered after leaving group",
"fix": "AdventureGroupTravelMgr::Observe clears an existing group-travel record when GetGroup returns null",
"regression_test": "PASS: test_adventure_group_travel.py",
"build": "PASS: CMake/Ninja Debug -j10, final artifact verified as ELF",
"installed": true,
"startup": "PASS: fresh ready, process 32369, ports 8085 and 8087",
"live_test": "PASS: Brindra left group, report showed no group travel state/grouped=0; same route changed from PathRejected to Moving with nav reason ok",
"loader_restored": "enabled=1 schema=1 busy=0 known=110",
"recovery_count": 13,
"raw_evidence": "/home/wow_admin/wow_cata_server/checkpoints/brindra-group-validator/",
"commit": null
}
@@ -0,0 +1,6 @@
{
"Clientless.Adventure.Loader.Enabled": 1,
"Clientless.TestBot.Enabled": 1,
"requested_at": "2026-10-31T13:41:55.253785+01:00",
"monotonic": 24044.355791678
}
@@ -0,0 +1,44 @@
[
{
"character_guid": "26",
"local_date": "20261031",
"hour_slot": "18",
"intent": "leisure",
"location_key": "poi_guard_542"
},
{
"character_guid": "26",
"local_date": "20261031",
"hour_slot": "19",
"intent": "groupformation",
"location_key": "poi_npc_314039"
},
{
"character_guid": "26",
"local_date": "20261031",
"hour_slot": "20",
"intent": "social",
"location_key": "goldshire_tavern"
},
{
"character_guid": "26",
"local_date": "20261031",
"hour_slot": "21",
"intent": "social",
"location_key": "goldshire_tavern"
},
{
"character_guid": "26",
"local_date": "20261031",
"hour_slot": "22",
"intent": "groupformation",
"location_key": "poi_npc_314313"
},
{
"character_guid": "26",
"local_date": "20261031",
"hour_slot": "23",
"intent": "raid",
"location_key": "instance_visit_at_3528"
}
]
@@ -0,0 +1,44 @@
[
{
"character_guid": "26",
"local_date": "20261031",
"hour_slot": "18",
"intent": "leisure",
"location_key": "poi_guard_542"
},
{
"character_guid": "26",
"local_date": "20261031",
"hour_slot": "19",
"intent": "groupformation",
"location_key": "poi_npc_314039"
},
{
"character_guid": "26",
"local_date": "20261031",
"hour_slot": "20",
"intent": "social",
"location_key": "goldshire_tavern"
},
{
"character_guid": "26",
"local_date": "20261031",
"hour_slot": "21",
"intent": "social",
"location_key": "goldshire_tavern"
},
{
"character_guid": "26",
"local_date": "20261031",
"hour_slot": "22",
"intent": "groupformation",
"location_key": "poi_npc_314313"
},
{
"character_guid": "26",
"local_date": "20261031",
"hour_slot": "23",
"intent": "raid",
"location_key": "instance_visit_at_3528"
}
]
@@ -0,0 +1,6 @@
[
{
"intent": "rest",
"location_key": "goldshire_tavern"
}
]
@@ -0,0 +1,132 @@
{
"snapshot_at": "2026-10-31T13:47:02.811753+01:00",
"bots": [
{
"guid": "53",
"name": "Noranda",
"race": "1",
"map": "571",
"position_x": "5728.95",
"position_y": "695.284",
"position_z": "688.058",
"taxi_path": "",
"faction": "alliance",
"position_source": "saveall immediately before shutdown; last database-saved position",
"individual_combat_teleport": "not exposed by current native status; aggregate safe logout and native flight/transport diagnostics captured separately",
"recovery_result": "Online login confirmed; admin teleport to faction capital; native adventure area confirmed map0/area5148 Alliance or map1/area5170 Horde."
},
{
"guid": "54",
"name": "Falaeriel",
"race": "10",
"map": "1",
"position_x": "1614",
"position_y": "-4323.1",
"position_z": "64.094",
"taxi_path": "",
"faction": "horde",
"position_source": "saveall immediately before shutdown; last database-saved position",
"individual_combat_teleport": "not exposed by current native status; aggregate safe logout and native flight/transport diagnostics captured separately",
"recovery_result": "Online login confirmed; admin teleport to faction capital; native adventure area confirmed map0/area5148 Alliance or map1/area5170 Horde."
},
{
"guid": "67",
"name": "Volasha",
"race": "8",
"map": "1",
"position_x": "-4483.48",
"position_y": "-1854",
"position_z": "132.231",
"taxi_path": "25 42 ",
"faction": "horde",
"position_source": "saveall immediately before shutdown; last database-saved position",
"individual_combat_teleport": "not exposed by current native status; aggregate safe logout and native flight/transport diagnostics captured separately",
"recovery_result": "Online login confirmed; admin teleport to faction capital; native adventure area confirmed map0/area5148 Alliance or map1/area5170 Horde."
},
{
"guid": "74",
"name": "Nimbin",
"race": "7",
"map": "1",
"position_x": "-3962.06",
"position_y": "-2889.03",
"position_z": "111.189",
"taxi_path": "179 389 ",
"faction": "alliance",
"position_source": "saveall immediately before shutdown; last database-saved position",
"individual_combat_teleport": "not exposed by current native status; aggregate safe logout and native flight/transport diagnostics captured separately",
"recovery_result": "Online login confirmed; admin teleport to faction capital; native adventure area confirmed map0/area5148 Alliance or map1/area5170 Horde."
},
{
"guid": "75",
"name": "Faeleran",
"race": "10",
"map": "1",
"position_x": "21.0141",
"position_y": "-3100.41",
"position_z": "119.486",
"taxi_path": "25 23 ",
"faction": "horde",
"position_source": "saveall immediately before shutdown; last database-saved position",
"individual_combat_teleport": "not exposed by current native status; aggregate safe logout and native flight/transport diagnostics captured separately",
"recovery_result": "Online login confirmed; admin teleport to faction capital; native adventure area confirmed map0/area5148 Alliance or map1/area5170 Horde."
},
{
"guid": "86",
"name": "Brindra",
"race": "3",
"map": "0",
"position_x": "-5298.76",
"position_y": "-598.388",
"position_z": "388.853",
"taxi_path": "",
"faction": "alliance",
"position_source": "saveall immediately before shutdown; last database-saved position",
"individual_combat_teleport": "not exposed by current native status; aggregate safe logout and native flight/transport diagnostics captured separately",
"recovery_result": "Online login confirmed; admin teleport to faction capital; native adventure area confirmed map0/area5148 Alliance or map1/area5170 Horde. First teleport drifted back to old movement; stopped behavior safely, repeated teleport verified area5148; schedule on restored."
},
{
"guid": "89",
"name": "Tahuima",
"race": "6",
"map": "571",
"position_x": "2718.31",
"position_y": "6232.1",
"position_z": "83.5786",
"taxi_path": "",
"faction": "horde",
"position_source": "saveall immediately before shutdown; last database-saved position",
"individual_combat_teleport": "not exposed by current native status; aggregate safe logout and native flight/transport diagnostics captured separately",
"recovery_result": "Online login confirmed; admin teleport to faction capital; native adventure area confirmed map0/area5148 Alliance or map1/area5170 Horde."
},
{
"guid": "125",
"name": "Koruhon",
"race": "6",
"map": "1",
"position_x": "2822",
"position_y": "-481.923",
"position_z": "112.377",
"taxi_path": "58 25 ",
"faction": "horde",
"position_source": "saveall immediately before shutdown; last database-saved position",
"individual_combat_teleport": "not exposed by current native status; aggregate safe logout and native flight/transport diagnostics captured separately",
"recovery_result": "Online login confirmed; admin teleport to faction capital; native adventure area confirmed map0/area5148 Alliance or map1/area5170 Horde."
},
{
"guid": "145",
"name": "Gorgar",
"race": "2",
"map": "530",
"position_x": "-1757.67",
"position_y": "5231.64",
"position_z": "-47.6846",
"taxi_path": "",
"faction": "horde",
"position_source": "saveall immediately before shutdown; last database-saved position",
"individual_combat_teleport": "not exposed by current native status; aggregate safe logout and native flight/transport diagnostics captured separately",
"recovery_result": "Online login confirmed; admin teleport to faction capital; native adventure area confirmed map0/area5148 Alliance or map1/area5170 Horde."
}
],
"completed": true
}
@@ -0,0 +1,20 @@
{
"build": "PASS -j10",
"installed": true,
"startup": "fresh ready and process25737 with ports8085/8087",
"tests": [
"plan-command parser and production EditPlan",
"intent state",
"day persistence",
"group departure"
],
"tests_passed": 4,
"live_validation": "GUID26 future hour20 persisted; restored original18-23 and compared all six rows; filtered read-only previews accepted",
"restored_loader": 1,
"restored_test_admission": true,
"raid_join_enabled": false,
"bg_join_enabled": false,
"recovery_count": 9,
"scope": "online AdventureBots; edits persisted for restart; no offline selection",
"git_commit": "not requested for this task"
}
@@ -0,0 +1,41 @@
# Safe-logout recovery list before route-planner performance build
- Safe logout requested: 2026-10-31 14:28 CET (approximately; console command)
- Snapshot/saveall: 2026-10-31 14:34 CET
- Loader and TestBot admission: disabled before the request
- Native aggregate at the five-minute limit: sessions 4, combat 0, transport 0,
flight/taxi 4, loading 0
- Teleport-in-progress is not exposed by the current native status command.
| GUID | Name | Faction | Saved position | Combat | Flight/taxi | Transport | Action/status before shutdown |
|---:|---|---|---|---:|---:|---:|---|
| 54 | Falaeriel | Horde | map 1, 5309.99 -3820.91 1723.56 | no | failed free flight, still flying/mounted | no | `travel_to_location`, no safe bounded corridor |
| 64 | Hildda | Alliance | map 0, -8802.94 642.241 106.207 | no | failed free flight, still flying/mounted | no | `travel_to_location`, no safe bounded corridor |
| 89 | Tahuima | Horde | map 1, 1754.87 -4343.36 115.299 | no | failed free flight, still flying/mounted | no | `travel_to_location`, no safe bounded corridor |
| 109 | Rixbin | Horde | map 530, 157 6047.06 57.2654; taxi 118 102 | no | native taxi active; prior free flight arrived | no | `travel_to_location`, taxi path 537 (118 to 102) |
The first three records demonstrate an existing safe-logout gap: failed free
flight deliberately holds the bot mounted in the air, so the native safety gate
can never become ready. Per the five-minute build rule they were not detached or
force-logged-out individually; the server is shut down cleanly before compiling.
After restart all four records must be checked and, if necessary, reset to
Stormwind or Orgrimmar before normal loader operation resumes.
## Incremental performance build
The first live measurement showed that limiting road exits alone was
insufficient. A second safe logout was requested at approximately 14:36 CET
with the loader still disabled. GUIDs 64, 89 and 109 logged out safely. GUID 54
remained in `FlightState::Failed` with movement flags still reporting flight
(`flight segment made no confirmed progress`) beyond the five-minute limit.
It is the sole recovery record for the incremental build; no individual forced
logout or flight interruption was used before the clean server shutdown.
## Recovery result
After each installed build the recorded characters were logged in with the
loader paused. All four had lost stale taxi/free-flight controller state.
Falaeriel, Tahuima and Rixbin were verified in Orgrimmar (allowed area 5170 or
1637); Hildda was verified in Stormwind (allowed area 5148). Their saved DB
positions are now in the correct faction capitals and have empty taxi paths.
Normal loader and TestBot admission were restored after the final build.
+66
View File
@@ -0,0 +1,66 @@
# Tagespläne per GM-Befehl bearbeiten
In der Konsole ohne Punkt, im Spiel mit führendem Punkt. Benötigt GM-Berechtigung.
```
clientless adventure editplan <guid|all> <Zeitraum> <Intent> <Location> <Fraktion> <MinLevel> <MaxLevel> <preview|apply>
```
- Ziel: einzelne Charakter-GUID oder `all` für alle eingeloggten AdventureBots.
Offlinebots, Dungeonbots und echte Spieler werden nicht verändert.
- Zeitraum: `current`, `next`, `next+1` (übernächste Stunde), eine Stunde `15`
oder inklusive Spanne `15-17`. Nur heutige aktuelle/künftige Stunden;
kein stiller Überlauf zum nächsten Tag. `current-next` ist ebenfalls möglich.
- Intent: vorhandener Intentname, `keep` zum Beibehalten oder `instance-or-raid`
für einen gemischten Test. Letzteres versucht bei geraden GUIDs Raid, sonst Dungeon;
bei `auto` fällt ein nicht zulässiger Raid auf Dungeon zurück.
- Location: konkreter Location-Key, `keep`, `auto` für die normale profil- und
zugangsabhängige Auswahl oder `capital` für einen gültigen Ort in Sturmwind bzw.
Orgrimmar. TravelGraph-Node-Keys sind nicht automatisch Location-Keys.
- Fraktion: `alliance`, `horde`, `both`; Levelgrenzen inklusive, 1–85.
- `preview` zeigt die geplanten Änderungen ohne Datenbank-, Bewegungs- oder
Zufallszustandsänderung. `apply` speichert die Änderungen im persistenten Tagesplan
und aktualisiert die laufenden Pläne ohne Neustart oder Lua-Änderung.
Ungültige Ziele werden gemeldet und für den betreffenden Bot wird die gesamte
angeforderte Spanne übersprungen. Andere gültige Bots werden bearbeitet.
Alle normalen Level-, Fraktions-, Gebiets-, Flug- und Instanzregeln bleiben aktiv.
Gruppenbildung benötigt eine zulässige Folgeaktivität; diese deshalb zuerst setzen.
Gruppenbildung in Stunde23 wird abgewiesen, weil kein Folgeblock existiert.
Aktuelle Änderungen aktivieren die Tagesplansteuerung und gehen durch den normalen
sicheren Intentwechsel. Gruppenführung und laufende Spielerinstanzen behalten ihre
normalen Regeln; dieser Befehl teleportiert nicht und löst keine Gruppen auf.
Künftige Änderungen lassen den aktuellen Intent laufen. Bei manuell deaktiviertem
Tagesplan muss für dessen spätere Ausführung `schedule <guid> on` verwendet werden.
Bereits begonnene vorgezogene Gruppenreisen werden durch künftige Änderungen nicht
zurückgesetzt. Die betroffene Anzahl und jeder übersprungene Bot werden ausgegeben.
## Klassischer Gruppentest
Zuerst die Ziele der Folgestunde setzen, dann die Gruppenbildungsphase. Beispiel
bei aktueller Stunde14: Gruppenbildung14–15, Aktivität15–16.
```
clientless adventure editplan all next instance-or-raid auto both 15 85 preview
clientless adventure editplan all next instance-or-raid auto both 15 85 apply
clientless adventure editplan all current groupformation capital both 15 85 preview
clientless adventure editplan all current groupformation capital both 15 85 apply
```
Für einen Test erst ab der nächsten Stunde die ersten beiden Befehle mit `next+1`
und die letzten beiden mit `next` verwenden. Bots ohne zulässiges Dungeon-/Raidziel
werden bei der Zielvergabe übersprungen; die Ausgabe vorher prüfen.
Weitere Beispiele:
```
clientless adventure editplan 26 current economy auto alliance 60 60 apply
clientless adventure editplan all 18-20 rest auto horde 1 40 preview
clientless adventure editplan all next keep poi_guard_22 alliance 15 85 preview
clientless adventure plan 26
clientless adventure schedule 26 on
```
Browserfreigaben werden von diesen Befehlen nicht verändert. Raid- und
Schlachtfeldbeitritt bleiben bei entsprechender Konfiguration deaktiviert.
+7 -12
View File
@@ -224,18 +224,13 @@ bool AdventureAreaMgr::AllowsEdge(Player const* player,ClientlessTravelEdge cons
if(!source || !target || *sourceEnd || *targetEnd || source>UINT32_MAX || target>UINT32_MAX)return false;
sObjectMgr->GetTaxiPath(uint32(source),uint32(target),path,cost);
if(!path || path>=sTaxiPathNodesByPath.size())return false;
auto const& points=sTaxiPathNodesByPath[path];
for(std::size_t i=0;i<points.size() && allowed;++i)
{
auto p=points[i];if(!p){allowed=false;break;}
Position next;next.Relocate(p->Loc.X,p->Loc.Y,p->Loc.Z);
allowed=AllowsPosition(player,p->ContinentID,next);
if(i && points[i-1] && points[i-1]->ContinentID==p->ContinentID)
{
auto previous=points[i-1];Position start;start.Relocate(previous->Loc.X,previous->Loc.Y,previous->Loc.Z);
allowed=allowed && AllowsSegment(player,p->ContinentID,start,next);
}
}
// Catalog hierarchy governs boarding and arrival, both checked above
// through the faction-marked taxi nodes. A native taxi may legitimately
// overfly an enemy or otherwise denied zone; sampling its entire spline
// every 40 yards both rejected valid travel and blocked the world thread
// for seconds for each new level/faction cache key. The native path
// lookup remains mandatory, but intermediate airspace is not admission.
allowed = true;
}
// Native transports have no land path across the sea. Their two dock nodes
// and explicit node restrictions decide access; portal destinations likewise.
@@ -4,6 +4,7 @@
#include "ClientlessTravelTelemetry.h"
#include "ClientlessTravelGraphMgr.h"
#include "AdventureBehaviorMgr.h"
#include "AdventurePlanCommand.h"
#include "AdventureCombatMgr.h"
#include "AdventureEncounterMgr.h"
#include "AdventureGroupPolicy.h"
@@ -1659,6 +1660,10 @@ bool AdventureBehaviorMgr::Command(ChatHandler * handler, char const * args)
std::string op;
uint32 guid = 0;
in >> op;
if (op == "editplan")
{
std::string args;std::getline(in,args);return EditPlan(handler,args);
}
if (op == "import") return sAdventureLocationMgr->Import(handler);
if (op == "repairimport") return sAdventureLocationMgr->Import(handler,true);
if (op == "reload")
@@ -2323,3 +2328,101 @@ void AdventureBehaviorMgr::ResumePersonalPlan(Player* player)
SetWanted(player,r,r.PersonalIntent,r.PersonalLocation,r.PersonalDeadline);
else SetWanted(player,r,"","",0);
}
bool AdventureBehaviorMgr::EditPlan(ChatHandler* handler,std::string const& args)
{
auto now=GameTime::GetGameTime();auto local=Local(now);auto date=DateOf(local);
AdventurePlanCommand::Request request;
if(!AdventurePlanCommand::Parse(args,local.tm_hour,request) ||
(request.Intent!="keep" && request.Intent!="instance-or-raid" && !Known(request.Intent)))
{
handler->SendSysMessage("Usage: clientless adventure editplan <guid|all> <current|next|next+N|hour|first-last> <intent|keep|instance-or-raid> <location|auto|capital|keep> <alliance|horde|both> <minLevel> <maxLevel> <preview|apply>. Online AdventureBots only; today, no past hours or midnight wrap.");
return false;
}
if(!sAdventureLocationMgr->EnsureLoaded())return false;
struct Change {uint32 Guid;AdventureBehaviorPolicy::Day Plan;};
std::vector<Change> changes;
std::vector<uint32> guids;for(auto const& entry:_records)guids.push_back(entry.first);
std::sort(guids.begin(),guids.end());
unsigned matched=0,skipped=0;
for(auto guid:guids)
{
auto session=sWorld->FindClientlessSession(guid);auto player=session?session->GetPlayer():nullptr;
if(!player || !player->IsInWorld() || !sClientlessPlayerMgr->IsManaged(guid))continue;
if(!AdventurePlanCommand::Matches(request,guid,player->getLevel(),player->GetTeamId()==TEAM_ALLIANCE?1:2))continue;
++matched;
auto const& original=_records.at(guid);
// Preview must not consume random state or modify histories, records or the DB.
if(original.Date!=date){++skipped;handler->PSendSysMessage("Skipped GUID %u: today's plan is not initialized.",guid);continue;}
auto copy=original;bool valid=true;
for(unsigned h=request.First;h<=request.Last;++h)
{
auto intent=request.Intent=="keep"?copy.Plan[h].Intent:request.Intent;
auto location=request.Location=="keep"?copy.Plan[h].Location:request.Location;
if(intent=="instance-or-raid")
{
intent=guid%2?"instance":"raid";
if(location=="auto")
{
location=PickLocation(player,copy,intent);
if(location.empty()){intent="instance";location=PickLocation(player,copy,intent);}
}
}
if(location=="auto")location=PickLocation(player,copy,intent);
if(location=="capital")
{
location.clear();
for(auto const& candidate:sAdventureLocationMgr->Locations())
if((candidate.ZoneId==(player->GetTeamId()==TEAM_ALLIANCE?1519u:1637u)) && Allowed(player,candidate,intent) &&
(location.empty() || candidate.Point.Id<location))location=candidate.Point.Id;
}
auto destination=sAdventureLocationMgr->Find(location);
if((h==23 && intent=="groupformation") || !destination || !Allowed(player,*destination,intent))
{valid=false;handler->PSendSysMessage("Skipped GUID %u: hour %u has no eligible destination for '%s'.",guid,h,intent.c_str());break;}
copy.Plan[h]={intent,location};
}
for(unsigned h=request.First;valid && h<=request.Last;++h)
if(copy.Plan[h].Intent=="groupformation")
{
auto const& next=copy.Plan[h+1];auto destination=sAdventureLocationMgr->Find(next.Location);
if(!AdventureGroupPolicy::Eligible(next.Intent) || !destination || !Allowed(player,*destination,next.Intent))
{valid=false;handler->PSendSysMessage("Skipped GUID %u: formation hour %u needs an eligible following activity; edit that hour first.",guid,h);}
}
// Each bot's requested range is atomic: no partial edit on an invalid slot.
if(!valid){++skipped;continue;}
changes.push_back({guid,copy.Plan});
}
if(!request.Preview && !changes.empty())
{
auto tx=WorldDatabase.BeginTransaction();
for(auto const& change:changes)
for(unsigned h=request.First;h<=request.Last;++h)
{
auto const& slot=change.Plan[h];auto key=slot.Location;WorldDatabase.EscapeString(key);
tx->PAppend("REPLACE INTO vcore_adventure_day (character_guid,local_date,hour_slot,intent,location_key) VALUES (%u,%u,%u,'%s','%s')",change.Guid,date,h,slot.Intent.c_str(),key.c_str());
}
WorldDatabase.DirectCommitTransaction(tx);
for(auto const& change:changes)
{
auto session=sWorld->FindClientlessSession(change.Guid);auto player=session?session->GetPlayer():nullptr;
if(!player)continue;
auto& r=_records.at(change.Guid);r.Plan=change.Plan;
if(request.First==unsigned(local.tm_hour))
{
ClearDeparture(player,r);r.Schedule=true;r.Enabled=true;r.Hour=-1;
r.ManualNextIntent.clear();r.ManualNextLocation.clear();
auto const& slot=r.Plan[local.tm_hour];auto end=local;end.tm_min=0;end.tm_sec=0;
end.tm_hour=local.tm_hour+1;
while(end.tm_hour<24 && r.Plan[end.tm_hour].Intent==slot.Intent && r.Plan[end.tm_hour].Location==slot.Location)++end.tm_hour;
end.tm_isdst=-1;SetWanted(player,r,slot.Intent,slot.Location,mktime(&end));
}
else if(r.Departure.At && !r.Departure.Departed(now) && request.First<=unsigned(local.tm_hour+1))
ClearDeparture(player,r);
}
}
for(auto const& change:changes)
for(unsigned h=request.First;h<=request.Last;++h)
handler->PSendSysMessage("%s GUID %u %02u:00-%02u:00 %s %s",request.Preview?"Preview":"Updated",change.Guid,h,h+1,change.Plan[h].Intent.c_str(),change.Plan[h].Location.c_str());
handler->PSendSysMessage("Plan edit %s: date=%u onlineMatched=%u eligible=%u skipped=%u hours=%u-%u. Offline bots unchanged; movement uses normal safe handover and group/instance rules.",request.Preview?"preview":"applied",date,matched,uint32(changes.size()),skipped,request.First,request.Last);
return true;
}
@@ -43,6 +43,7 @@ public:
std::string InteractionIntent(Player* player) const;
std::string InteractionSchedule(Player* player);
private:
bool EditPlan(ChatHandler* handler, std::string const& args);
enum class Phase {Idle, Travel, OnSite, Switching, Handover, WaitingGroup};
struct RecoveryScene
{
@@ -130,7 +130,14 @@ void AdventureGroupTravelMgr::SampleLeader(Player* player,Leader& state,time_t n
void AdventureGroupTravelMgr::Observe(Player* player,uint32 diff)
{
auto group=player->GetGroup();
if(!group)return;
// Group travel installs an owner-specific road adapter and path validator.
// Remove both as soon as the player leaves the group; otherwise unrelated
// autonomous travel can keep using formation safety rules indefinitely.
if(!group)
{
if(_records.count(player->GetGUID().GetCounter()))Clear(player);
return;
}
auto inserted=_records.try_emplace(player->GetGUID().GetCounter());
auto& record=inserted.first->second;
if(inserted.second)
@@ -0,0 +1,67 @@
#ifndef VCORE_ADVENTURE_PLAN_COMMAND_H
#define VCORE_ADVENTURE_PLAN_COMMAND_H
#include <limits>
#include <sstream>
#include <string>
namespace AdventurePlanCommand
{
struct Request
{
unsigned Guid=0, First=0, Last=0, MinLevel=1, MaxLevel=85, Faction=0;
bool Preview=true;
std::string Intent, Location;
};
inline bool Number(std::string const& text, unsigned& value)
{
if(text.empty())return false;
unsigned result=0;
for(char c:text)
{
if(c<'0' || c>'9' || result>(std::numeric_limits<unsigned>::max()-unsigned(c-'0'))/10)return false;
result=result*10+unsigned(c-'0');
}
value=result;return true;
}
inline bool Hour(std::string const& text,unsigned current,unsigned& value)
{
if(text=="current")value=current;
else if(text=="next")value=current+1;
else if(text.compare(0,5,"next+")==0)
{
unsigned offset=0;
if(!Number(text.substr(5),offset) || offset>23)return false;
value=current+1+offset;
}
else if(!Number(text,value))return false;
return value>=current && value<24;
}
inline bool Parse(std::string const& args,unsigned current,Request& out)
{
std::istringstream input(args);
std::string target,hours,faction,min,max,mode,extra;
Request request;
if(!(input>>target>>hours>>request.Intent>>request.Location>>faction>>min>>max>>mode) || input>>extra)return false;
if(target!="all" && (!Number(target,request.Guid) || !request.Guid))return false;
auto dash=hours.find('-');
if(dash==std::string::npos)
{
if(!Hour(hours,current,request.First))return false;
request.Last=request.First;
}
else if(!Hour(hours.substr(0,dash),current,request.First) || !Hour(hours.substr(dash+1),current,request.Last) || request.Last<request.First)return false;
if(faction=="alliance")request.Faction=1;
else if(faction=="horde")request.Faction=2;
else if(faction!="both")return false;
if(!Number(min,request.MinLevel) || !Number(max,request.MaxLevel) || request.MinLevel<1 || request.MaxLevel>85 || request.MinLevel>request.MaxLevel)return false;
if(mode=="apply")request.Preview=false;
else if(mode!="preview")return false;
out=request;return true;
}
inline bool Matches(Request const& request,unsigned guid,unsigned level,unsigned faction)
{
return (!request.Guid || request.Guid==guid) && level>=request.MinLevel && level<=request.MaxLevel && (!request.Faction || request.Faction==faction);
}
}
#endif
@@ -574,6 +574,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
// Reuse all start-point observations only WITHIN this immutable planning
// call, including its two search tiers. Never cache live Player paths globally.
std::map<ClientlessGraphProbeCache::Key,ClientlessPathDiagnostics> startProbes;
std::map<std::pair<ClientlessGraphProbeCache::Key, bool>, float> flightEstimates;
auto analyzeStart=[&](Position const& target,ClientlessPathDiagnostics& result)
{
auto key=ClientlessGraphProbeCache::MakeKey(player->GetMapId(),player->GetInstanceId(),player->GetPositionX(),player->GetPositionY(),player->GetPositionZ(),target.GetPositionX(),target.GetPositionY(),target.GetPositionZ());
@@ -621,12 +622,26 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
{ error="destination location denied by area catalog"; return false; }
}
bool const flightEnabled = sClientlessFlightMgr->Enabled(player);
constexpr std::uint64_t MaxFlightEstimatesPerPlan = 16;
auto flightCost = [&](ClientlessTravelNode const& from, uint32 map, float x, float y, float z, bool access = false)
{
if (!flightEnabled || from.MapId != map ||
(blockedBridgeKeys && blockedBridgeKeys->count("flight_origin:"+from.Key)))
return std::numeric_limits<float>::infinity();
Position a,b; a.Relocate(from.X,from.Y,from.Z,from.O); b.Relocate(x,y,z);
auto const estimateKey = std::make_pair(
ClientlessGraphProbeCache::MakeKey(map, player->GetInstanceId(),
from.X, from.Y, from.Z, x, y, z), access);
auto cachedEstimate = flightEstimates.find(estimateKey);
if (cachedEstimate != flightEstimates.end())
return cachedEstimate->second;
// Flight links are optional accelerators around the persistent graph.
// A pathological graph must not monopolize the world thread while
// constructing an implicit complete air graph. Cached estimates remain
// usable after the cap; unexamined links simply stay unavailable in
// this plan and ordinary graph travel can still win.
if (trace.FlightCalls >= MaxFlightEstimatesPerPlan)
return std::numeric_limits<float>::infinity();
auto begin=std::chrono::steady_clock::now();++trace.FlightCalls;
float result=sClientlessFlightMgr->Estimate(player,map,a,b,access);
if(access && std::isfinite(result))
@@ -637,6 +652,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
40+distance*0.4f:180+distance*1.5f;
}
trace.FlightUs+=std::chrono::duration_cast<std::chrono::microseconds>(std::chrono::steady_clock::now()-begin).count();
flightEstimates.emplace(estimateKey, result);
return result;
};
if (startRadius <= 0.0f)
@@ -649,6 +665,47 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
bridgeRadius = MaxVirtualNodeBridge;
bridgeRadius = std::min(bridgeRadius, MaxVirtualNodeBridge);
// The overwhelmingly common free-flight case is a same-map Location. Do
// not construct a macro graph first: Estimate already enforces licenses,
// mount flags, area policy, distance and the complete catalog segment.
// Native departure/landing validation still happens in FlightMgr::Start.
if (locationGoal && locationGoal->MapId == player->GetMapId())
{
ClientlessTravelNode departureNode;
departureNode.Key = "flight_departure";
departureNode.MapId = player->GetMapId();
departureNode.X = player->GetPositionX();
departureNode.Y = player->GetPositionY();
departureNode.Z = player->GetPositionZ();
float const directFlight = flightCost(departureNode, locationGoal->MapId,
locationGoal->X, locationGoal->Y, locationGoal->Z);
if (std::isfinite(directFlight))
{
ClientlessTravelRouteStep departure;
departure.NodeKey = "flight_departure";
departure.NodeType = "ground";
departure.EdgeType = "start";
departure.MapId = player->GetMapId();
departure.X = player->GetPositionX();
departure.Y = player->GetPositionY();
departure.Z = player->GetPositionZ();
route.push_back(std::move(departure));
ClientlessTravelRouteStep flight;
flight.NodeKey = "flight_location";
flight.NodeType = "location";
flight.EdgeType = "free_flight";
flight.MapId = locationGoal->MapId;
flight.X = locationGoal->X;
flight.Y = locationGoal->Y;
flight.Z = locationGoal->Z;
route.push_back(std::move(flight));
complete = true;
reachedNode = "flight_location";
return true;
}
}
struct State
{
std::string Node;
@@ -1005,6 +1062,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
? std::min(300.0f, locationGoal->AnchorRadius) : 0.0f;
std::vector<ExitCandidate> rawExitCandidates;
std::unordered_map<std::string, std::vector<ExitCandidate>> exitsByFrom;
std::size_t flightExitProbes = 0;
if (locationGoal || flightEnabled)
{
auto const& targetNode = destinationItr->second;
@@ -1046,6 +1104,12 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
float travelled = 0.0f;
ExitCandidate nearest;
nearest.DistanceSq = std::numeric_limits<float>::infinity();
struct RankedFlightExit
{
float LowerBound = 0.0f;
ExitCandidate Candidate;
};
std::vector<RankedFlightExit> rankedFlightExits;
for (uint32 i = 1; i + 1 < ordered.size(); ++i)
{
float dx = ordered[i].X - ordered[i - 1].X;
@@ -1078,18 +1142,59 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
blockedBridgeKeys->count("ground_exit_edge:" + identity);
candidate.LocalCost = locationGoal && !groundExitExhausted && dsq <= locationAccessRadius*locationAccessRadius
? std::sqrt(dsq) : std::numeric_limits<float>::infinity();
// A recorded edge can contain hundreds of samples. Calling
// FlightMgr::Estimate for every sample made the synchronous
// world-thread planner quadratic in road geometry (13k+
// area checks for one route in production). Keep an exact
// ground candidate, but validate only the geometrically best
// flight exits below. Estimate still performs all catalog and
// native flight-permission checks for those candidates.
if (std::isfinite(candidate.LocalCost) &&
(!std::isfinite(nearest.DistanceSq) ||
candidate.RoadCost + candidate.LocalCost < nearest.RoadCost + nearest.LocalCost))
nearest = candidate;
if (flightEnabled && !(blockedBridgeKeys && blockedBridgeKeys->count("flight_exit:" + exitKey)))
{
float const direct = std::sqrt(dsq);
// Forty plus 0.4 * distance is the cheapest possible
// free-flight cost. It is an admissible ordering key;
// rejected area segments simply advance to the next item.
rankedFlightExits.push_back({ candidate.RoadCost + 40.0f + direct * 0.4f,
std::move(candidate) });
}
}
std::sort(rankedFlightExits.begin(), rankedFlightExits.end(),
[](RankedFlightExit const& left, RankedFlightExit const& right)
{ return left.LowerBound < right.LowerBound; });
constexpr std::size_t MaxFlightExitProbesPerEdge = 2;
for (std::size_t i = 0; i < std::min(MaxFlightExitProbesPerEdge, rankedFlightExits.size()); ++i)
{
constexpr std::size_t MaxFlightExitProbesPerPlan = 8;
if (flightExitProbes >= MaxFlightExitProbesPerPlan)
break;
++flightExitProbes;
RankedFlightExit const& ranked = rankedFlightExits[i];
ExitCandidate candidate = ranked.Candidate;
ClientlessTravelNode exitNode;
exitNode.MapId=point.MapId;exitNode.X=point.X;exitNode.Y=point.Y;exitNode.Z=point.Z;
float const air = blockedBridgeKeys && blockedBridgeKeys->count("flight_exit:"+exitKey)
? std::numeric_limits<float>::infinity() : flightCost(exitNode,goalMap,goalX,goalY,goalZ);
if (air < candidate.LocalCost) { candidate.LocalCost=air;candidate.Flight=true; }
if (!std::isfinite(candidate.LocalCost)) continue;
// Never replace a node goal with an unvalidated ground exit.
if (!locationGoal && !candidate.Flight) continue;
exitNode.MapId = candidate.Point.MapId;
exitNode.X = candidate.Point.X;
exitNode.Y = candidate.Point.Y;
exitNode.Z = candidate.Point.Z;
float const air = flightCost(exitNode, goalMap, goalX, goalY, goalZ);
if (!std::isfinite(air))
continue;
candidate.LocalCost = air;
candidate.Flight = true;
if (std::isfinite(nearest.DistanceSq) &&
candidate.RoadCost+candidate.LocalCost >= nearest.RoadCost+nearest.LocalCost) continue;
candidate.RoadCost + candidate.LocalCost >= nearest.RoadCost + nearest.LocalCost)
continue;
nearest = std::move(candidate);
}
// Never replace a node goal with an unvalidated ground exit.
if (!locationGoal && std::isfinite(nearest.DistanceSq) && !nearest.Flight)
nearest.DistanceSq = std::numeric_limits<float>::infinity();
if (std::isfinite(nearest.DistanceSq))
rawExitCandidates.push_back(std::move(nearest));
}
@@ -1115,6 +1220,29 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
float bestGoalCost = std::numeric_limits<float>::infinity();
std::string bestGoalState;
bool bestFinalFlight = false;
// Free flight is a macro connector, not a reason to turn every graph map
// into a complete directed graph. A flyer only needs direct links to the
// requested destination and to nodes that enter a map-changing or physical
// transport edge (portal, ship, zeppelin or tram). Taxi nodes are excluded:
// a free flyer can already reach their same-map destination directly, and
// adding every taxi source recreates an almost-complete graph. Ordinary
// same-map ground
// nodes remain reachable through their recorded graph, while a location
// goal is evaluated directly from every expanded node below.
std::unordered_set<std::string> flightConnectorNodes;
flightConnectorNodes.insert(destinationNode);
for (auto const& edgeGroup : _edges)
for (ClientlessTravelEdge const& edge : edgeGroup.second)
{
auto fromItr = _nodes.find(edge.FromNode);
auto toItr = _nodes.find(edge.ToNode);
bool const changesMap = fromItr != _nodes.end() && toItr != _nodes.end() &&
fromItr->second.MapId != toItr->second.MapId;
bool const physicalTransport = edge.Type == "portal" || edge.Type == "ship" ||
edge.Type == "zeppelin" || edge.Type == "tram" || edge.Type == "transport";
if (changesMap || physicalTransport)
flightConnectorNodes.insert(edge.FromNode);
}
// C8.20: a virtual entry is represented in the queue by its FAR
// endpoint. Check exits BEFORE that endpoint independently: otherwise
// an exit halfway along the road would pay for the entire remaining edge.
@@ -1259,6 +1387,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
// Persistent TravelGraph edges always reset the bridge guard.
auto edgeItr = _edges.find(current.Value.Node);
bool hasPersistentContinuation = false;
if (edgeItr != _edges.end())
{
for (ClientlessTravelEdge const& edge : edgeItr->second)
@@ -1268,6 +1397,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
continue;
if (blockedEdgeIds && edge.DatabaseId && blockedEdgeIds->count(edge.DatabaseId))
continue;
hasPersistentContinuation = true;
State next{ edge.ToNode, false };
std::string const nextKey = stateKey(next);
@@ -1294,9 +1424,12 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
if (flightEnabled && !portalArrival)
{
auto const& origin=_nodes.at(current.Value.Node);
for (auto const& pair:_nodes)
for (std::string const& targetKey : flightConnectorNodes)
{
auto const& target=pair.second;
auto targetItr = _nodes.find(targetKey);
if (targetItr == _nodes.end())
continue;
auto const& target=targetItr->second;
if (target.Key==origin.Key || target.MapId!=origin.MapId) continue;
State next{target.Key,false};std::string key=stateKey(next);
auto old=distance.find(key);
@@ -1335,7 +1468,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
from.Key.rfind("portal_at_", 0) == 0 &&
from.Key.size() >= 7 &&
from.Key.compare(from.Key.size() - 7, 7, "_source") == 0;
if (!futurePortalSource && persistentHops[currentKey] > 0 &&
if (!futurePortalSource && !hasPersistentContinuation && persistentHops[currentKey] > 0 &&
!(blockedBridgeKeys &&
blockedBridgeKeys->count(MakeLocalBridgeKey(from.Key, "virtual_ground_entry"))))
{
@@ -55,15 +55,15 @@ namespace ObjectAccessor{std::unordered_map<ObjectGuid,Player*>players;Player*Fi
namespace GameTime{time_t now=100;time_t GetGameTime(){return now;}}
struct Areas{void*LookupEntry(unsigned){return nullptr;}}sAreaTriggerStore;
enum class ClientlessActionExecutionState{Moving,SelectingTarget,Completed,Failed};
struct Executor{struct Status{ClientlessActionExecutionState State=ClientlessActionExecutionState::Completed;}status;unsigned clears=0;auto GetStatus(unsigned){return &status;}bool RequestStopAtNode(unsigned){return true;}void Clear(unsigned){++clears;status.State=ClientlessActionExecutionState::Completed;}void SetGroundPathAdapter(unsigned,std::function<bool(Player*,std::vector<Position>&)>){}}executor;auto sClientlessActionExecutorMgr=&executor;
struct Executor{struct Status{ClientlessActionExecutionState State=ClientlessActionExecutionState::Completed;}status;unsigned clears=0,adapters=0;auto GetStatus(unsigned){return &status;}bool RequestStopAtNode(unsigned){return true;}void Clear(unsigned){++clears;status.State=ClientlessActionExecutionState::Completed;}void SetGroundPathAdapter(unsigned,std::function<bool(Player*,std::vector<Position>&)> adapter){adapters+=adapter?1:-1;}}executor;auto sClientlessActionExecutorMgr=&executor;
struct ClientlessAction{std::string Name;std::unordered_map<std::string,std::string>Parameters;};
struct Core{unsigned queued=0;std::vector<std::string>nodes;unsigned GetQueuedActionCount(unsigned){return queued;}bool QueueAction(unsigned,ClientlessAction action){++queued;nodes.push_back(action.Parameters["node"]);return true;}void ClearQueuedActions(unsigned){queued=0;}}core;auto sClientlessPlayerMgr=&core;
enum class ClientlessTravelState{Moving,Arrived,Stuck,PathRejected};
struct ClientlessPathDiagnostics{bool Complete=true,Suspicious=false;float PathLength=20;};
struct Travel{struct Status{ClientlessTravelState State=ClientlessTravelState::Arrived;Position Target;}status;bool valid=true;unsigned starts=0,cancels=0;std::vector<Position>targets;
struct Travel{struct Status{ClientlessTravelState State=ClientlessTravelState::Arrived;Position Target;}status;bool valid=true;unsigned starts=0,cancels=0,validators=0;std::vector<Position>targets;
auto GetStatus(unsigned){return &status;}bool AnalyzePath(Player*,Position const&,ClientlessPathDiagnostics&d){d.Complete=valid;return valid;}
bool StartTravel(Player*,Position const&target,bool=true,bool=false){++starts;targets.push_back(target);status.Target=target;status.State=ClientlessTravelState::Moving;return true;}
std::vector<Position>const*GetActivePath(unsigned){return nullptr;}void CancelTravel(unsigned){++cancels;status.State=ClientlessTravelState::Arrived;}void SetPathValidator(unsigned,std::function<bool(Player*,std::vector<Position>const&)>){}
std::vector<Position>const*GetActivePath(unsigned){return nullptr;}void CancelTravel(unsigned){++cancels;status.State=ClientlessTravelState::Arrived;}void SetPathValidator(unsigned,std::function<bool(Player*,std::vector<Position>const&)> validator){validators+=validator?1:-1;}
}travel;auto sClientlessTravelMgr=&travel;
struct PathGenerator{struct Point{float x,y,z;};Player*p;std::vector<Point>points;PathGenerator(Player*who):p(who){}bool CalculatePath(float x,float y,float z){points={{p->GetPositionX(),p->GetPositionY(),p->GetPositionZ()},{x,y,z}};return travel.valid;}auto const&GetPath(){return points;}};
struct Graph{bool FindNearestNodeToPosition(unsigned,float,float,float,std::string&n,float&distance){n="nearest";distance=5;return true;}}graph;auto sClientlessTravelGraphMgr=&graph;
@@ -150,6 +150,12 @@ int main(){
leader.taxi=false;executor.status.State=ClientlessActionExecutionState::Completed;
// Recorded roads are bounded and validated, never a spline through a cliff.
std::vector<Position>road;for(unsigned x=0;x<=100;x+=10)road.emplace_back(x,0,0);leader.moving=false;map.cliff=true;assert(!mgr.AdaptRoad(&a,road));map.cliff=false;road.clear();for(unsigned x=0;x<=100;x+=10)road.emplace_back(x,0,0);assert(mgr.AdaptRoad(&a,road) && road.back().GetPositionX()<=60);
// Leaving a group clears formation-owned adapters and validators before the
// next autonomous route. This prevents valid MMAP paths being rejected by a
// stale group formation safety check.
assert(mgr._records.count(26)&&executor.adapters==2&&travel.validators==2);
auto cancelsBefore=travel.cancels;a.g=nullptr;mgr.Observe(&a,500);
assert(!mgr._records.count(26)&&executor.adapters==1&&travel.validators==1&&travel.cancels==cancelsBefore+1);
}
'''
with tempfile.TemporaryDirectory() as d:
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Exercise the production parser and plan editor, including persistence and preview."""
from pathlib import Path
import subprocess,tempfile
root=Path(__file__).resolve().parents[2]
source=(root/'src/server/game/World/AdventureBehaviorMgr.cpp').read_text()
start=source.index('bool AdventureBehaviorMgr::EditPlan(')
end=len(source)
code=r'''
#include "AdventurePlanCommand.h"
#include <algorithm>
#include <array>
#include <cassert>
#include <ctime>
#include <map>
#include <string>
#include <vector>
#include <cstdarg>
#include <cstdio>
using uint32=unsigned;constexpr unsigned TEAM_ALLIANCE=0;
namespace AdventureGroupPolicy{bool Eligible(std::string const&s){return s=="instance"||s=="raid"||s=="adventure";}}
namespace AdventureBehaviorPolicy{struct Slot{std::string Intent,Location;};using Day=std::array<Slot,24>;}
struct Player{unsigned guid,level=60,team=0;bool world=true;bool IsInWorld(){return world;}unsigned getLevel(){return level;}unsigned GetTeamId(){return team;}};
struct Session{Player* player;Player*GetPlayer(){return player;}};
struct World{std::map<unsigned,Session> sessions;Session*FindClientlessSession(unsigned g){auto i=sessions.find(g);return i==sessions.end()?nullptr:&i->second;}} world;auto sWorld=&world;
struct Managed{bool IsManaged(unsigned){return true;}} managed;auto sClientlessPlayerMgr=&managed;
struct Location{unsigned ZoneId;struct{std::string Id;}Point;};
struct Locations{std::vector<Location> locations{{1519,{"sw"}},{1637,{"org"}},{1,{"dungeon"}},{1,{"raid"}},{1,{"forbidden"}}};
bool EnsureLoaded(){return true;}auto const& Locations(){return locations;}Location const*Find(std::string const& key){for(auto&l:locations)if(l.Point.Id==key)return &l;return nullptr;}} locations;auto sAdventureLocationMgr=&locations;
struct Tx{std::vector<std::string> sql;void PAppend(char const*fmt,...){char buf[1024];va_list ap;va_start(ap,fmt);vsnprintf(buf,sizeof(buf),fmt,ap);va_end(ap);sql.push_back(buf);}};
struct Database{Tx tx;unsigned commits=0;Tx*BeginTransaction(){tx.sql.clear();return &tx;}void EscapeString(std::string&){}void DirectCommitTransaction(Tx*&){++commits;}}WorldDatabase;
struct ChatHandler{std::vector<std::string> messages;void SendSysMessage(char const*s){messages.push_back(s);}void PSendSysMessage(char const*fmt,...){char buf[1024];va_list ap;va_start(ap,fmt);vsnprintf(buf,sizeof(buf),fmt,ap);va_end(ap);messages.push_back(buf);}};
namespace GameTime{long GetGameTime(){return 100000;}}
std::tm Local(long){std::tm tm{};tm.tm_year=126;tm.tm_mon=9;tm.tm_mday=31;tm.tm_hour=10;return tm;}
unsigned DateOf(std::tm const&){return 20261031;}
bool Known(std::string const&s){return s=="social"||s=="adventure"||s=="instance"||s=="raid"||s=="groupformation";}
struct AdventureBehaviorMgr{
struct DepartureState{long At=0;bool departed=false;bool Departed(long)const{return departed;}};
struct Record{unsigned Date=20261031;bool Schedule=true,Enabled=true;int Hour=10;std::string ManualNextIntent,ManualNextLocation;AdventureBehaviorPolicy::Day Plan;DepartureState Departure;unsigned picks=0;};
std::map<unsigned,Record>_records;unsigned switches=0,clears=0;
bool EditPlan(ChatHandler*,std::string const&);
bool Allowed(Player*p,Location const&l,std::string const&i){return l.Point.Id!="forbidden"&&(i!="raid"||p->level>=60)&&(l.Point.Id!="sw"||p->team==0)&&(l.Point.Id!="org"||p->team==1);}
std::string PickLocation(Player*p,Record&r,std::string const&i){++r.picks;if(i=="raid"&&p->level<60)return "";return i=="instance"?"dungeon":i=="raid"?"raid":p->team?"org":"sw";}
void ClearDeparture(Player*,Record&r){++clears;r.Departure={};}void SetWanted(Player*,Record&,std::string const&,std::string const&,long){++switches;}
};
'''.replace('struct Locations{','struct LocationCatalog{').replace('}} locations;', '}} locations;')
# Class and member names must differ in C++.
code+=source[start:end]
code+=r'''
int main(){
using namespace AdventurePlanCommand;Request q;
assert(Parse("all current social capital both 1 85 preview",10,q));assert(q.First==10&&q.Last==10&&q.Preview);
assert(Parse("43 next+2 raid auto alliance 60 85 apply",10,q));assert(q.First==13&&q.Guid==43&&q.Faction==1);
assert(Parse("all 12-15 keep auto horde 1 85 apply",10,q)&&q.Last==15);
for(auto s:{"all next social auto both 0 85 apply","all next social auto both 85 1 apply","all next social auto both 1 86 apply","all 9 social auto both 1 85 apply","all next social auto both 1 85 typo","0 next social auto both 1 85 apply","all 14-12 social auto both 1 85 apply","all next social auto both 1 85 apply extra","4294967296 next social auto both 1 85 apply"})assert(!Parse(s,10,q));
assert(!Parse("all next social auto both 1 85 apply",23,q));assert(!Parse("all next+23 social auto both 1 85 apply",10,q));
assert(Parse("43 next raid auto alliance 60 85 apply",10,q));assert(Matches(q,43,60,1));assert(!Matches(q,44,60,1));assert(!Matches(q,43,59,1));assert(!Matches(q,43,60,2));
Player a{43,60,0},b{44,30,1},c{45,60,0};world.sessions={{43,{&a}},{44,{&b}},{45,{&c}}};
AdventureBehaviorMgr mgr;for(auto g:{43,44,45}){auto&r=mgr._records[g];for(auto&s:r.Plan)s={"social","sw"};}mgr._records[45].Date=20261030;
ChatHandler h;
assert(mgr.EditPlan(&h,"all next raid auto both 1 85 preview"));assert(WorldDatabase.commits==0&&mgr.switches==0);assert(mgr._records[43].picks==0&&mgr._records[43].Plan[11].Intent=="social");
assert(mgr.EditPlan(&h,"all next raid auto both 1 85 apply"));assert(WorldDatabase.commits==1&&WorldDatabase.tx.sql.size()==1);assert(mgr._records[43].Plan[11].Intent=="raid");assert(mgr._records[44].Plan[11].Intent=="social");assert(mgr.switches==0);
assert(mgr.EditPlan(&h,"44 next instance-or-raid auto horde 15 59 apply"));assert(mgr._records[44].Plan[11].Intent=="instance");
assert(mgr.EditPlan(&h,"43 12-14 adventure capital alliance 60 60 apply"));assert(WorldDatabase.tx.sql.size()==3);assert(mgr._records[43].Plan[14].Location=="sw");assert(mgr._records[43].Plan[15].Intent=="social");
auto commits=WorldDatabase.commits;assert(mgr.EditPlan(&h,"43 next raid forbidden both 1 85 apply"));assert(WorldDatabase.commits==commits);
assert(mgr.EditPlan(&h,"43 22-23 groupformation capital both 1 85 apply"));assert(WorldDatabase.commits==commits&&mgr._records[43].Plan[22].Intent=="social");
assert(mgr.EditPlan(&h,"43 current adventure capital both 1 85 apply"));assert(mgr.switches==1&&mgr.clears==1&&mgr._records[43].Hour==-1);
assert(WorldDatabase.tx.sql[0].find("20261031,10,'adventure','sw'")!=std::string::npos);
// Formation validates its next block, not just the meeting location.
mgr._records[43].Plan[11]={"social","sw"};auto rejected=WorldDatabase.commits;
assert(mgr.EditPlan(&h,"43 current groupformation capital both 1 85 apply"));assert(WorldDatabase.commits==rejected);
mgr._records[43].Plan[11]={"raid","raid"};assert(mgr.EditPlan(&h,"43 current groupformation capital both 1 85 apply"));assert(mgr._records[43].Plan[10].Intent=="groupformation");
mgr._records[43].Departure.At=1;mgr._records[43].Departure.departed=true;auto cleared=mgr.clears;
assert(mgr.EditPlan(&h,"43 next adventure auto both 1 85 apply"));assert(mgr.clears==cleared);
mgr._records[43].Departure.departed=false;assert(mgr.EditPlan(&h,"43 next adventure auto both 1 85 apply"));assert(mgr.clears==cleared+1);
assert(!mgr.EditPlan(&h,"all current nonsense capital both 1 85 apply"));
}
'''
with tempfile.TemporaryDirectory() as td:
p=Path(td);(p/'test.cpp').write_text(code)
subprocess.run(['c++','-std=c++17','-I'+str(root/'src/server/game/World'),str(p/'test.cpp'),'-o',str(p/'test')],check=True)
subprocess.run([str(p/'test')],check=True)
print('Adventure plan commands: PASS')